feat(t-paliad-155): real Claude SKILL.md + per-user tmux session

Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.

Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.

Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
  in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
  session arg + caches per-key; ResetSession derives session from UserID
  and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
  takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.

Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.

Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.

Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
This commit is contained in:
m
2026-05-08 12:42:57 +02:00
parent 319221ff83
commit 97a412498d
10 changed files with 611 additions and 486 deletions

View File

@@ -47,7 +47,7 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. | | `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). | | `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
| `PALIADIN_TMUX_SESSION` | optional (default `paliad-paliadin`) | tmux session name the Paliadin service uses for its long-lived `claude` pane. | | `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. | | `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy. > *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.

View File

@@ -189,9 +189,9 @@ func main() {
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)", log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail) cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
} else if _, err := exec.LookPath("tmux"); err == nil { } else if _, err := exec.LookPath("tmux"); err == nil {
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION") sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR") responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, tmuxSession, responseDir) svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail) log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail)
} else { } else {
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users) svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
@@ -255,6 +255,7 @@ func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, erro
SSHHost: host, SSHHost: host,
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"), SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
SSHPort: 22022, SSHPort: 22022,
SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
} }
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" { if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
n, err := strconv.Atoi(p) n, err := strconv.Atoi(p)

View File

@@ -287,14 +287,16 @@ func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
} }
} }
// handlePaliadinReset clears the Claude conversation context. // handlePaliadinReset kills the caller's Paliadin tmux session so the
// next turn boots a fresh claude pane (per-user — see t-paliad-155).
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) { func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) { if !requirePaliadinOwner(w, r) {
return return
} }
uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner
ctx, cancel := newDetachedContext(10 * time.Second) ctx, cancel := newDetachedContext(10 * time.Second)
defer cancel() defer cancel()
if err := paliadinSvc.ResetSession(ctx); err != nil { if err := paliadinSvc.ResetSession(ctx, uid); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "reset failed: " + err.Error(), "error": "reset failed: " + err.Error(),
}) })

View File

@@ -57,7 +57,10 @@ const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
// so callers don't have to nil-check on every entry point. // so callers don't have to nil-check on every entry point.
type Paliadin interface { type Paliadin interface {
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
ResetSession(ctx context.Context) error // ResetSession kills the user's tmux session entirely so the next
// RunTurn boots a fresh claude pane. Per-user since each Paliad user
// has their own session (t-paliad-155).
ResetSession(ctx context.Context, userID uuid.UUID) error
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
@@ -73,21 +76,28 @@ type paliadinDB struct {
} }
// LocalPaliadinService runs the local tmux+claude PoC (t-paliad-146). // LocalPaliadinService runs the local tmux+claude PoC (t-paliad-146).
// Hardcoded single-user, single-tmux-window scope. Used on m's laptop; // Used on m's laptop; not deployed to prod (the Dokploy container has no
// not deployed to prod (the Dokploy container has no `claude` CLI — // `claude` CLI — see RemotePaliadinService for that path).
// see RemotePaliadinService for that path). //
// Per-user tmux session: every Paliad user gets their own session named
// `<sessionPrefix>-<userid8>` (first 8 hex chars of the user's UUID),
// created on demand. The persona + response protocol are loaded from
// the Paliadin skill (~/.claude/skills/paliadin/SKILL.md, installed via
// scripts/install-paliadin-skill); there is no in-process system prompt.
type LocalPaliadinService struct { type LocalPaliadinService struct {
paliadinDB paliadinDB
tmuxSession string sessionPrefix string
responseDir string responseDir string
// Cached pane target ("session:window-idx") once the voice window is // Cached pane targets per user-session, keyed by tmux session name.
// either discovered or created. Reset to "" if the pane dies. // A session entry maps to "session:window-idx"; cleared when the
// pane dies or ResetSession is called for that user.
mu sync.Mutex mu sync.Mutex
paneTarget string panes map[string]string
// Single in-flight turn at a time. PoC scope — one user (m), serialised // Single in-flight turn at a time across all users. PoC scope —
// by a session-level mutex. Production v1 would queue / fan out. // claude CLI panes share the host's terminal noise; serialising
// keeps log output unambiguous.
turnMu sync.Mutex turnMu sync.Mutex
} }
@@ -111,22 +121,37 @@ func (s *paliadinDB) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error
return strings.EqualFold(email, PaliadinOwnerEmail), nil return strings.EqualFold(email, PaliadinOwnerEmail), nil
} }
// NewLocalPaliadinService wires the local-tmux PoC backend. Falls back // NewLocalPaliadinService wires the local-tmux PoC backend. The
// to default tmux session + response dir when env vars are empty. // sessionPrefix arg is the prefix every per-user tmux session inherits —
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *LocalPaliadinService { // the actual session name is `<prefix>-<userid8>`. Falls back to
if tmuxSession == "" { // defaults when env vars are empty.
tmuxSession = "paliad-paliadin" func NewLocalPaliadinService(db *sqlx.DB, users *UserService, sessionPrefix, responseDir string) *LocalPaliadinService {
if sessionPrefix == "" {
sessionPrefix = "paliad-paliadin"
} }
if responseDir == "" { if responseDir == "" {
responseDir = "/tmp/paliadin" responseDir = "/tmp/paliadin"
} }
return &LocalPaliadinService{ return &LocalPaliadinService{
paliadinDB: paliadinDB{db: db, users: users}, paliadinDB: paliadinDB{db: db, users: users},
tmuxSession: tmuxSession, sessionPrefix: sessionPrefix,
responseDir: responseDir, responseDir: responseDir,
panes: make(map[string]string),
} }
} }
// sessionNameFor returns the tmux session name for a given user. Per
// design (t-paliad-155): one persistent session per Paliad user keyed
// on the first 8 hex chars of their UUID. Conversation history piles
// up across visits; `ResetSession` is the user-driven escape hatch.
func (s *LocalPaliadinService) sessionNameFor(userID uuid.UUID) string {
short := userID.String()
if len(short) >= 8 {
short = short[:8]
}
return s.sessionPrefix + "-" + short
}
// PaliadinTurn is the audit row. // PaliadinTurn is the audit row.
type PaliadinTurn struct { type PaliadinTurn struct {
TurnID uuid.UUID `db:"turn_id" json:"turn_id"` TurnID uuid.UUID `db:"turn_id" json:"turn_id"`
@@ -199,8 +224,8 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
return nil, fmt.Errorf("paliadin: insert turn row: %w", err) return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
} }
// Ensure tmux session + Claude pane. // Ensure tmux session + Claude pane (per-user — keyed off UserID).
target, err := s.ensurePane(ctx) target, err := s.ensurePane(ctx, req.UserID)
if err != nil { if err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive") _ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err) return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err)
@@ -212,8 +237,9 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err) return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
} }
// Send the framed prompt. The system prompt teaches Claude to react // Send the framed prompt. The Paliadin skill at
// to the [PALIADIN:turn_id] envelope by writing the response file. // ~/.claude/skills/paliadin/SKILL.md description-matches on this
// envelope and writes the response to the per-turn file.
envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage)) envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage))
if err := s.sendToPane(ctx, target, envelope); err != nil { if err := s.sendToPane(ctx, target, envelope); err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive") _ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
@@ -260,20 +286,25 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
}, nil }, nil
} }
// ResetSession sends `/clear` to the Claude pane so the next turn starts // ResetSession kills the user's tmux session entirely so the next
// from a clean conversation. Used by the "New conversation" button. // RunTurn boots a fresh claude pane. With skill-based persona load
func (s *LocalPaliadinService) ResetSession(ctx context.Context) error { // (~/.claude/skills/paliadin/SKILL.md) the new pane re-acquires the
// protocol contract automatically — no system-prompt re-send needed.
func (s *LocalPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
session := s.sessionNameFor(userID)
s.mu.Lock() s.mu.Lock()
target := s.paneTarget delete(s.panes, session)
s.mu.Unlock() s.mu.Unlock()
if target == "" {
// Nothing to reset; the next RunTurn will spin up a fresh pane. // `tmux kill-session` returns non-zero if the session doesn't exist;
// that's fine — the next RunTurn will recreate it. Swallow the error
// only when it's a benign "no such session" so genuine tmux failures
// (binary missing, daemon dead) still surface to the caller.
if err := runTmux(ctx, "has-session", "-t", session); err != nil {
return nil return nil
} }
if err := s.sendToPane(ctx, target, "/clear"); err != nil { return runTmux(ctx, "kill-session", "-t", session)
return err
}
return nil
} }
// ListRecentTurns reads the last N turns visible to the caller. // ListRecentTurns reads the last N turns visible to the caller.
@@ -427,34 +458,40 @@ func (s *paliadinDB) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinSt
// ============================================================================= // =============================================================================
// ensurePane returns the tmux target ("session:window-idx") of the live // ensurePane returns the tmux target ("session:window-idx") of the live
// Claude pane, creating both session and window if missing. // Claude pane for this user, creating both session and window if
func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) { // missing. The persona + response protocol are loaded from the Paliadin
// skill on first user turn (Claude's skill router auto-matches the
// `[PALIADIN:` envelope), so no in-process system-prompt send is
// required.
func (s *LocalPaliadinService) ensurePane(ctx context.Context, userID uuid.UUID) (string, error) {
session := s.sessionNameFor(userID)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
// Cheap path: if we have a cached target and it's still alive, reuse. // Cheap path: cached target still alive? Reuse.
if s.paneTarget != "" && s.paneAlive(ctx, s.paneTarget) { if cached, ok := s.panes[session]; ok && cached != "" && s.paneAlive(ctx, cached) {
return s.paneTarget, nil return cached, nil
} }
// Ensure session. // Ensure session.
if err := runTmux(ctx, "has-session", "-t", s.tmuxSession); err != nil { if err := runTmux(ctx, "has-session", "-t", session); err != nil {
// Create detached. // Create detached.
if err := runTmux(ctx, "new-session", "-d", "-s", s.tmuxSession); err != nil { if err := runTmux(ctx, "new-session", "-d", "-s", session); err != nil {
return "", fmt.Errorf("new-session: %w", err) return "", fmt.Errorf("new-session: %w", err)
} }
} }
// Look for an existing window tagged with @paliadin-scope=chat. // Look for an existing window tagged with @paliadin-scope=chat.
if existing := s.findChatWindow(ctx); existing != "" { if existing := s.findChatWindow(ctx, session); existing != "" {
s.paneTarget = existing s.panes[session] = existing
return existing, nil return existing, nil
} }
// No window — create one running `claude` in a fresh pane. Must be // No window — create one running `claude` in a fresh pane. Must be
// interactive: claude reads stdin, so the tmux pane behaves like a // interactive: claude reads stdin, so the tmux pane behaves like a
// terminal. We use `new-window -P -F` to print the new index back. // terminal. We use `new-window -P -F` to print the new index back.
out, err := runTmuxOut(ctx, "new-window", "-t", s.tmuxSession, out, err := runTmuxOut(ctx, "new-window", "-t", session,
"-n", "claude-paliadin", "-n", "claude-paliadin",
"-P", "-F", "#{window_index}", "-P", "-F", "#{window_index}",
"claude") "claude")
@@ -462,7 +499,7 @@ func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
return "", fmt.Errorf("new-window claude: %w", err) return "", fmt.Errorf("new-window claude: %w", err)
} }
idx := strings.TrimSpace(out) idx := strings.TrimSpace(out)
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx) target := fmt.Sprintf("%s:%s", session, idx)
// Wait for Claude's prompt indicator. Claude Code's interactive // Wait for Claude's prompt indicator. Claude Code's interactive
// prompt rendering varies but always settles into a state where the // prompt rendering varies but always settles into a state where the
@@ -476,30 +513,18 @@ func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
_ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat") _ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat")
_ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin") _ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin")
// Send the bootstrap system prompt so Claude knows who it is and how s.panes[session] = target
// to reply (write to the per-turn file with [paliadin-meta] trailer).
if err := s.sendToPane(ctx, target, paliadinSystemPrompt(s.responseDir)); err != nil {
return "", fmt.Errorf("send system prompt: %w", err)
}
// Give Claude a moment to absorb the system prompt before turns flow.
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(2 * time.Second):
}
s.paneTarget = target
return target, nil return target, nil
} }
func (s *LocalPaliadinService) findChatWindow(ctx context.Context) string { func (s *LocalPaliadinService) findChatWindow(ctx context.Context, session string) string {
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession, out, err := runTmuxOut(ctx, "list-windows", "-t", session,
"-F", "#{window_index}") "-F", "#{window_index}")
if err != nil { if err != nil {
return "" return ""
} }
for _, idx := range strings.Fields(out) { for _, idx := range strings.Fields(out) {
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx) target := fmt.Sprintf("%s:%s", session, idx)
scope, err := runTmuxOut(ctx, "show-window-option", scope, err := runTmuxOut(ctx, "show-window-option",
"-t", target, "-v", "@paliadin-scope") "-t", target, "-v", "@paliadin-scope")
if err == nil && strings.TrimSpace(scope) == "chat" { if err == nil && strings.TrimSpace(scope) == "chat" {

View File

@@ -1,269 +0,0 @@
package services
// Paliadin system prompt — Phase 0 PoC.
//
// This is the bootstrap message sent to the long-lived `claude` pane
// once, right after the pane is created. It defines who Paliadin is,
// how to reply (write to the per-turn response file, emit a
// [paliadin-meta] trailer block), what SQL to run, and how visibility
// is enforced.
//
// Design: docs/design-paliadin-2026-05-07.md §0.5.3 + §2.2.1.
//
// Conventions:
// - The prompt MUST end with the response-file write rule, since that
// is the contract the Go service polls on.
// - SQL recipes MUST always include the visibility predicate
// (paliad.can_see_project) on any project-scoped query — even
// though m's global_role=global_admin technically lets him see
// everything, we keep the muscle memory consistent with the
// production-v1 design.
// - The trailer format is stable; the trailer parser in paliadin.go
// must be kept in sync.
import "strings"
// paliadinSystemPrompt returns the full bootstrap message for a fresh
// Claude pane. The response_dir argument is the path where Claude must
// write its per-turn response files.
//
// Built via concatenation rather than fmt.Sprintf because the prompt
// contains German genitive apostrophes ("m's") that Sprintf misreads as
// format verbs.
func paliadinSystemPrompt(responseDir string) string {
return strings.TrimSpace(`
Du bist Paliadin — der eingebaute KI-Assistent in Paliad, m's Patentpraxis-Plattform. Du hilfst m bei seiner täglichen Arbeit: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
# Persönlichkeit
- Direkt, kompetent, juristisch präzise. Keine Floskeln.
- Sprich wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung — nicht wie ein generischer Chatbot.
- Belege jede konkrete Aussage mit einem Tool-Call oder einer Zitat-Quelle. Niemals raten.
- Antworte standardmäßig auf Deutsch (m's Arbeitssprache). Wenn m auf Englisch fragt, antworte auf Englisch.
- Keine Emojis, keine "Ich helfe dir gerne!"-Phrasen.
# Antwort-Protokoll (KRITISCH)
Jede Anfrage von m kommt im Format: ` + "`[PALIADIN:turn_id] <Frage>`" + `
Sobald du die turn_id liest:
1. Recherchiere mit deinen Tools (siehe SQL-Rezepte unten).
2. Formuliere eine knappe, faktenbasierte Antwort in Markdown.
3. Schreibe die Antwort in eine Datei: ` + "`Write(" + responseDir + "/{turn_id}.txt)`" + `
4. WICHTIG: Schreib SOFORT, sobald du die Antwort hast. Das System wartet (Timeout: 60s).
5. Häng am Ende des Antworttextes IMMER einen [paliadin-meta]-Block an — sonst weiß das System nicht, was du gemacht hast.
# Trailer-Format (PFLICHT am Ende jeder Antwort)
Trenne den Block mit einer Leerzeile + ---, dann:
[paliadin-meta]
used_tools: <komma-separierte Tool-Namen, leer wenn keiner verwendet>
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
classifier_tag: <data | concept | navigation | meta | other>
[/paliadin-meta]
Beispiel:
[paliadin-meta]
used_tools: search_my_deadlines, lookup_court
rows_seen: 3, 1
classifier_tag: data
[/paliadin-meta]
Die classifier_tag-Werte:
- ` + "`data`" + ` — m fragt nach seinen eigenen Daten ("welche Frist…", "auf welchem Projekt…")
- ` + "`concept`" + ` — m fragt nach einem juristischen Begriff/Verfahren ("was ist Klageerwiderung?")
- ` + "`navigation`" + ` — m sucht eine Seite/Funktion in Paliad ("wie öffne ich…")
- ` + "`meta`" + ` — Frage über Paliadin selbst, oder Smalltalk
- ` + "`other`" + ` — alles andere (Recherche, Web-Wissen)
# Action-Chips (optional, aber gerne nutzen)
Wenn du eine konkrete Folge-Aktion anbieten kannst, embed einen Chip-Marker direkt in den Antworttext. Das Frontend rendert ihn als anklickbaren Button:
- ` + "`[#deadline-OPEN:c47bd2-...]`" + ` — öffnet die Fristen-Detailseite
- ` + "`[#projekt-OPEN:slug-x]`" + ` — öffnet die Projekt-Detailseite
- ` + "`[chip:nav:/projects/abc-123]`" + ` — beliebige Navigation
- ` + "`[chip:filter:status=pending&due=this_week]`" + ` — gefilterter Inbox-Link
Verwende NUR IDs/Slugs, die du tatsächlich aus einem Tool-Call zurückbekommen hast. Niemals erfinden.
# Hard Rules
1. **Keine Erfindungen.** Wenn ein Tool keine Daten liefert, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
2. **Jede konkrete Aussage über m's eigene Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
3. **Schreibe nichts in die DB.** Du bist read-only. Wenn m etwas ändern will, sag ihm wo in Paliad.
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS ` + "`paliad.can_see_project(project_id)`" + ` enthalten. Konsistenz mit der späteren Multi-User-Version.
5. **Nicht über die Daten anderer User spekulieren**, selbst wenn m sie namentlich erwähnt — frag nach Projekt-ID/Slug.
# SQL-Rezepte
Du hast Zugriff auf zwei Datenquellen über das Supabase MCP (mcp__supabase__execute_sql):
- ` + "`paliad.*`" + ` — m's Patent-Praxis-Daten (Projekte, Fristen, Termine, Parteien, Gerichte, Glossar, Deadline-Rules)
- ` + "`data.*`" + ` — youpc.org UPC-Rechtsprechung (Urteile, Headnotes, Knowledge Graph) — selbe physische DB!
## 1. whats_on_my_plate — m's Dashboard-Übersicht
` + "```sql" + `
SELECT
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date = current_date) AS today,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending'
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
(SELECT count(*) FROM paliad.appointments a
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at::date = current_date) AS appointments_today;
` + "```" + `
## 2. list_my_projects
` + "```sql" + `
SELECT id, kind, label, status, parent_id, path
FROM paliad.projects
WHERE paliad.can_see_project(id)
AND status = 'active'
ORDER BY path
LIMIT 25;
` + "```" + `
## 3. get_project_detail (gegeben slug oder id)
` + "```sql" + `
SELECT p.*,
(SELECT json_agg(d ORDER BY d.due_date)
FROM paliad.deadlines d WHERE d.project_id = p.id
AND paliad.can_see_project(d.project_id)) AS deadlines,
(SELECT json_agg(a ORDER BY a.start_at)
FROM paliad.appointments a WHERE a.project_id = p.id
AND paliad.can_see_project(a.project_id)) AS appointments,
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
FROM paliad.projects p
WHERE paliad.can_see_project(p.id)
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
LIMIT 1;
` + "```" + `
## 4. search_my_deadlines (status / Datum / Projekt)
` + "```sql" + `
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
FROM paliad.deadlines d
JOIN paliad.projects p ON p.id = d.project_id
WHERE paliad.can_see_project(d.project_id)
AND ($status::text IS NULL OR d.status = $status)
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
ORDER BY d.due_date ASC
LIMIT 25;
` + "```" + `
## 5. list_my_appointments (Zeitfenster)
` + "```sql" + `
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
FROM paliad.appointments a
LEFT JOIN paliad.projects p ON p.id = a.project_id
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at >= $from
AND a.start_at <= $to
ORDER BY a.start_at ASC
LIMIT 25;
` + "```" + `
## 6. lookup_court (Gerichtskatalog — firm-wide reference)
` + "```sql" + `
SELECT c.slug, c.name, c.country, c.kind, c.address
FROM paliad.courts c
WHERE c.name ILIKE '%' || $q || '%'
OR c.slug ILIKE '%' || $q || '%'
ORDER BY similarity(c.name, $q) DESC
LIMIT 10;
` + "```" + `
## 7. lookup_glossary_term (Patent-Glossar, DE+EN)
` + "```sql" + `
-- Hinweis: Glossar ist statisch in internal/handlers/glossary.go.
-- Der Service lädt JSON beim Boot. Wenn du einen Begriff suchst, frag mich
-- direkt im Chat — m hat den Glossar-Volltext im Kopf, oder ich kann ihn
-- aus paliad.deadline_rules.legal_source ableiten.
` + "```" + `
## 8. lookup_deadline_rule (Fristenrechner-Konzepte)
` + "```sql" + `
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
FROM paliad.deadline_rules r
WHERE r.concept_label ILIKE '%' || $q || '%'
OR r.rule_code ILIKE '%' || $q || '%'
OR r.legal_source ILIKE '%' || $q || '%'
ORDER BY similarity(r.concept_label, $q) DESC
LIMIT 5;
` + "```" + `
## 9. lookup_youpc_case (UPC-Rechtsprechung — cross-schema!)
` + "```sql" + `
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
j.proceedings_type, j.decision_date, j.headnote_summary,
j.tags
FROM data.judgments j
WHERE j.upc_number ILIKE '%' || $q || '%'
OR j.headnote_summary ILIKE '%' || $q || '%'
OR j.tags::text ILIKE '%' || $q || '%'
ORDER BY j.decision_date DESC
LIMIT 5;
` + "```" + `
Volltext eines Urteils (wenn m fragt "was steht in dem Urteil?"):
` + "```sql" + `
SELECT content
FROM data.judgment_markdown_content
WHERE judgment_node_id = <node_id>
ORDER BY chunk_index
LIMIT 1;
` + "```" + `
# Beispiel-Antwort
m fragt: ` + "`[PALIADIN:abc-123] welche fristen sind diese woche fällig?`" + `
Du machst:
1. ` + "`mcp__supabase__execute_sql`" + ` mit Rezept #4 (search_my_deadlines), $status='pending', $due_after=current_date, $due_before=current_date+7
2. Du bekommst z.B. 3 Zeilen zurück.
3. Du schreibst:
` + "```" + `
Write("/tmp/paliadin/abc-123.txt", """
Diese Woche stehen 3 Fristen an:
- **16.05.** Klageerwiderung auf Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
- **17.05.** Replik auf BMW v. Daimler [#deadline-OPEN:e92a01-3]
- **20.05.** Wiedereinsetzungsantrag auf Bosch-Patent [#deadline-OPEN:f31b09-7]
Willst du eine davon im Detail anschauen?
---
[paliadin-meta]
used_tools: search_my_deadlines
rows_seen: 3
classifier_tag: data
[/paliadin-meta]
""")
` + "```" + `
# Wichtig
Der erste turn-Envelope, den du nach diesem System-Prompt bekommst, ist eine richtige m-Anfrage. Antworte gemäß Protokoll. Bei der allerersten Anfrage darfst du dich kurz vorstellen ("Hi m, ich bin Paliadin — bereit."), danach normaler Modus.
`)
}

View File

@@ -52,6 +52,7 @@ type RemotePaliadinConfig struct {
SSHUser string // m SSHUser string // m
SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600) SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600)
KnownHostsPath string // /tmp/paliadin-known_hosts KnownHostsPath string // /tmp/paliadin-known_hosts
SessionPrefix string // tmux session prefix; per-user session is "<prefix>-<userid8>"
} }
// RemotePaliadinService implements Paliadin against a remote // RemotePaliadinService implements Paliadin against a remote
@@ -60,27 +61,30 @@ type RemotePaliadinService struct {
paliadinDB paliadinDB
cfg RemotePaliadinConfig cfg RemotePaliadinConfig
// Single in-flight turn. mRiver's claude pane is single-user; we // Serialise turns across all users. mRiver's host has finite tmux
// serialise turns the same way LocalPaliadinService does. // concurrency anyway, and Paliadin turns are short. Per-user
// fan-out can ship in v2 if it ever bottlenecks.
turnMu sync.Mutex turnMu sync.Mutex
// Health-check cache. Avoids probing mRiver on every turn — once // Health-check cache, keyed by per-user session name. Avoids
// the cache is warm, RunTurn skips the probe for 10 seconds. // probing mRiver on every turn — once a session's cache is warm,
// RunTurn skips the probe for 10 seconds.
healthMu sync.Mutex healthMu sync.Mutex
healthOK bool health map[string]healthCacheEntry
healthCheckedAt time.Time
// Lazy bootstrap state. The system prompt only needs to be sent
// once per claude pane; on first RunTurn after a paliad restart we
// inject it, and remember we did so we don't re-send.
bootstrapMu sync.Mutex
bootstrapped bool
// Hook for tests — when non-nil, callShim delegates here instead // Hook for tests — when non-nil, callShim delegates here instead
// of exec'ing ssh. Production code never sets this. // of exec'ing ssh. Production code never sets this.
callShimHook func(ctx context.Context, args ...string) ([]byte, error) callShimHook func(ctx context.Context, args ...string) ([]byte, error)
} }
// healthCacheEntry is one row in the health cache, keyed off tmux
// session name. We cache success only — failures re-probe so a flap
// surfaces immediately when paliad reboots into a healthy mRiver.
type healthCacheEntry struct {
ok bool
checkedAt time.Time
}
// NewRemotePaliadinService wires the remote backend. Call only when // NewRemotePaliadinService wires the remote backend. Call only when
// PALIADIN_REMOTE_HOST is set in the environment; the constructor does // PALIADIN_REMOTE_HOST is set in the environment; the constructor does
// not probe mRiver — first probe happens on the first RunTurn call via // not probe mRiver — first probe happens on the first RunTurn call via
@@ -92,12 +96,28 @@ func NewRemotePaliadinService(db *sqlx.DB, users *UserService, cfg RemotePaliadi
if cfg.SSHUser == "" { if cfg.SSHUser == "" {
cfg.SSHUser = "m" cfg.SSHUser = "m"
} }
if cfg.SessionPrefix == "" {
cfg.SessionPrefix = "paliad-paliadin"
}
return &RemotePaliadinService{ return &RemotePaliadinService{
paliadinDB: paliadinDB{db: db, users: users}, paliadinDB: paliadinDB{db: db, users: users},
cfg: cfg, cfg: cfg,
health: make(map[string]healthCacheEntry),
} }
} }
// sessionNameFor returns the per-user tmux session name. Per-user
// keying (t-paliad-155): one persistent session per Paliad user keyed
// on the first 8 hex chars of their UUID. Conversation history piles
// up across visits; ResetSession is the user-driven escape hatch.
func (s *RemotePaliadinService) sessionNameFor(userID uuid.UUID) string {
short := userID.String()
if len(short) >= 8 {
short = short[:8]
}
return s.cfg.SessionPrefix + "-" + short
}
// RunTurn drives one Q&A round against the remote claude pane. Same // RunTurn drives one Q&A round against the remote claude pane. Same
// audit-row contract as LocalPaliadinService: write the row first, run // audit-row contract as LocalPaliadinService: write the row first, run
// the turn, complete the row on success, mark error on failure. // the turn, complete the row on success, mark error on failure.
@@ -120,24 +140,25 @@ func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
return nil, fmt.Errorf("paliadin: insert turn row: %w", err) return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
} }
session := s.sessionNameFor(req.UserID)
// Health-gate before paying the cost of a real turn. Caches OK for // Health-gate before paying the cost of a real turn. Caches OK for
// 10 s so a fast back-to-back chat doesn't probe every time. // 10 s per session so a fast back-to-back chat doesn't probe every
if err := s.healthGate(ctx); err != nil { // time.
if err := s.healthGate(ctx, session); err != nil {
_ = s.markTurnError(ctx, turnID, "mriver_unreachable") _ = s.markTurnError(ctx, turnID, "mriver_unreachable")
return nil, err return nil, err
} }
// Lazy bootstrap — first turn after a paliad restart sends the // Persona + response protocol live in the Paliadin skill at
// system prompt; subsequent turns skip. // ~/.claude/skills/paliadin/SKILL.md on mRiver. Claude's skill
if err := s.ensureBootstrapped(ctx); err != nil { // router auto-matches the [PALIADIN: envelope so no in-process
_ = s.markTurnError(ctx, turnID, "bootstrap_failed") // bootstrap (system-prompt-via-tmux-keystroke) is needed any more.
return nil, err
}
msg := sanitiseForTmux(req.UserMessage) msg := sanitiseForTmux(req.UserMessage)
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg)) msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
body, err := s.callShim(ctx, "run-turn", turnID.String(), msgB64) body, err := s.callShim(ctx, "run-turn", session, turnID.String(), msgB64)
if err != nil { if err != nil {
_ = s.markTurnError(ctx, turnID, classifySSHError(err)) _ = s.markTurnError(ctx, turnID, classifySSHError(err))
return nil, err return nil, err
@@ -165,55 +186,50 @@ func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
}, nil }, nil
} }
// ResetSession sends `/clear` to the remote claude pane. // ResetSession kills the user's tmux session on mRiver entirely so the
func (s *RemotePaliadinService) ResetSession(ctx context.Context) error { // next RunTurn boots a fresh claude pane. Skill-based persona load
if _, err := s.callShim(ctx, "reset"); err != nil { // means the new pane re-acquires the Paliadin protocol contract on
return fmt.Errorf("paliadin: reset: %w", err) // first turn — no system-prompt re-send needed.
func (s *RemotePaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
session := s.sessionNameFor(userID)
// Drop the cached health entry so the next turn re-probes against
// the fresh session.
s.healthMu.Lock()
delete(s.health, session)
s.healthMu.Unlock()
if _, err := s.callShim(ctx, "reset", session); err != nil {
return fmt.Errorf("paliadin: reset %s: %w", session, err)
} }
return nil return nil
} }
// healthGate runs the shim's `health` verb at most once per 10 s. // healthGate runs the shim's `health <session>` verb at most once per
// Returns ErrMRiverUnreachable wrapping the underlying error on miss. // 10 s per session. Returns ErrMRiverUnreachable wrapping the
func (s *RemotePaliadinService) healthGate(ctx context.Context) error { // underlying error on miss.
func (s *RemotePaliadinService) healthGate(ctx context.Context, session string) error {
s.healthMu.Lock() s.healthMu.Lock()
defer s.healthMu.Unlock() defer s.healthMu.Unlock()
if s.healthOK && time.Since(s.healthCheckedAt) < 10*time.Second { if entry, ok := s.health[session]; ok && entry.ok && time.Since(entry.checkedAt) < 10*time.Second {
return nil return nil
} }
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() defer cancel()
out, err := s.callShim(probeCtx, "health") out, err := s.callShim(probeCtx, "health", session)
s.healthCheckedAt = time.Now()
if err != nil { if err != nil {
s.healthOK = false // Don't cache failures — re-probe on every miss so a recovery
// surfaces immediately.
delete(s.health, session)
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err) return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
} }
if strings.TrimSpace(string(out)) != "ok" { if strings.TrimSpace(string(out)) != "ok" {
s.healthOK = false delete(s.health, session)
return fmt.Errorf("%w: shim returned %q", ErrMRiverUnreachable, string(out)) return fmt.Errorf("%w: shim returned %q", ErrMRiverUnreachable, string(out))
} }
s.healthOK = true s.health[session] = healthCacheEntry{ok: true, checkedAt: time.Now()}
return nil
}
// ensureBootstrapped sends the Paliadin system prompt to the remote
// claude pane on first call. Idempotent — subsequent calls return nil
// without doing any work.
func (s *RemotePaliadinService) ensureBootstrapped(ctx context.Context) error {
s.bootstrapMu.Lock()
defer s.bootstrapMu.Unlock()
if s.bootstrapped {
return nil
}
prompt := paliadinSystemPrompt("/tmp/paliadin")
promptB64 := base64.StdEncoding.EncodeToString([]byte(prompt))
if _, err := s.callShim(ctx, "bootstrap", promptB64); err != nil {
return fmt.Errorf("paliadin: bootstrap: %w", err)
}
s.bootstrapped = true
return nil return nil
} }
@@ -309,7 +325,7 @@ func (s *DisabledPaliadinService) RunTurn(ctx context.Context, req TurnRequest)
return nil, ErrPaliadinDisabled return nil, ErrPaliadinDisabled
} }
func (s *DisabledPaliadinService) ResetSession(ctx context.Context) error { func (s *DisabledPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
return ErrPaliadinDisabled return ErrPaliadinDisabled
} }

View File

@@ -8,8 +8,15 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
) )
// testSession is the per-user session name we pass into healthGate /
// callShim from tests. The shape mirrors what RunTurn would derive for
// a real user.
const testSession = "paliad-paliadin-deadbeef"
// Tests for the remote-Paliadin backend. Every test bypasses exec via // Tests for the remote-Paliadin backend. Every test bypasses exec via
// the callShimHook field — no real ssh is ever invoked, no DB rows are // the callShimHook field — no real ssh is ever invoked, no DB rows are
// written. Tests that would need DB I/O (audit row insert/complete on // written. Tests that would need DB I/O (audit row insert/complete on
@@ -77,13 +84,13 @@ func TestHealthGate_CachesOnSuccess(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"}) s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) { s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
if len(args) != 1 || args[0] != "health" { if len(args) != 2 || args[0] != "health" || args[1] != testSession {
t.Errorf("unexpected callShim args: %v", args) t.Errorf("unexpected callShim args: %v", args)
} }
return []byte("ok\n"), nil return []byte("ok\n"), nil
} }
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
if err := s.healthGate(context.Background()); err != nil { if err := s.healthGate(context.Background(), testSession); err != nil {
t.Fatalf("healthGate iteration %d: %v", i, err) t.Fatalf("healthGate iteration %d: %v", i, err)
} }
} }
@@ -100,7 +107,7 @@ func TestHealthGate_RetriesAfterFailure(t *testing.T) {
return nil, errors.New("ssh: Connection refused") return nil, errors.New("ssh: Connection refused")
} }
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
err := s.healthGate(context.Background()) err := s.healthGate(context.Background(), testSession)
if !errors.Is(err, ErrMRiverUnreachable) { if !errors.Is(err, ErrMRiverUnreachable) {
t.Errorf("iteration %d: err %v; want wrapping ErrMRiverUnreachable", i, err) t.Errorf("iteration %d: err %v; want wrapping ErrMRiverUnreachable", i, err)
} }
@@ -116,60 +123,32 @@ func TestHealthGate_RejectsUnexpectedReply(t *testing.T) {
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) { s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
return []byte("not-ok"), nil return []byte("not-ok"), nil
} }
err := s.healthGate(context.Background()) err := s.healthGate(context.Background(), testSession)
if !errors.Is(err, ErrMRiverUnreachable) { if !errors.Is(err, ErrMRiverUnreachable) {
t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for non-ok reply", err) t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for non-ok reply", err)
} }
} }
func TestEnsureBootstrapped_RunsOnce(t *testing.T) { func TestHealthGate_PerSessionCache(t *testing.T) {
// Two sessions must each get their own probe — caching is per-key,
// not global.
var calls int32 var calls int32
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"}) s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) { s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
if len(args) != 2 || args[0] != "bootstrap" { return []byte("ok"), nil
t.Errorf("unexpected callShim args: %v", args)
} }
// args[1] is the base64'd system prompt — no need to decode in if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
// the test; just sanity-check it isn't trivially empty. t.Fatalf("session A first probe: %v", err)
if len(args[1]) < 100 {
t.Errorf("bootstrap prompt suspiciously short: %d bytes", len(args[1]))
} }
return []byte("ok\n"), nil if err := s.healthGate(context.Background(), "paliad-paliadin-bbbbbbbb"); err != nil {
t.Fatalf("session B first probe: %v", err)
} }
for i := 0; i < 3; i++ { if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
if err := s.ensureBootstrapped(context.Background()); err != nil { t.Fatalf("session A second probe: %v", err)
t.Fatalf("ensureBootstrapped iteration %d: %v", i, err)
}
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("expected 1 callShim call (bootstrap is one-shot); got %d", got)
}
}
func TestEnsureBootstrapped_RetriesOnFailure(t *testing.T) {
var calls int32
var failOnce atomic.Bool
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
if failOnce.CompareAndSwap(false, true) {
return nil, errors.New("ssh: transient failure")
}
return []byte("ok\n"), nil
}
if err := s.ensureBootstrapped(context.Background()); err == nil {
t.Fatal("first call should error")
}
if err := s.ensureBootstrapped(context.Background()); err != nil {
t.Fatalf("second call should succeed: %v", err)
}
// Third call should be a cache hit (bootstrapped flag set on success).
if err := s.ensureBootstrapped(context.Background()); err != nil {
t.Fatalf("third call should be cached: %v", err)
} }
if got := atomic.LoadInt32(&calls); got != 2 { if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("expected 2 callShim calls (1 fail + 1 succeed; 3rd cached); got %d", got) t.Errorf("expected 2 callShim calls (1 per session, A reuses cache on 3rd); got %d", got)
} }
} }
@@ -180,14 +159,14 @@ func TestHealthGate_CacheExpires(t *testing.T) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
return []byte("ok"), nil return []byte("ok"), nil
} }
if err := s.healthGate(context.Background()); err != nil { if err := s.healthGate(context.Background(), testSession); err != nil {
t.Fatalf("first probe: %v", err) t.Fatalf("first probe: %v", err)
} }
// Force the cached timestamp to expire. // Force the cached timestamp to expire.
s.healthMu.Lock() s.healthMu.Lock()
s.healthCheckedAt = time.Now().Add(-11 * time.Second) s.health[testSession] = healthCacheEntry{ok: true, checkedAt: time.Now().Add(-11 * time.Second)}
s.healthMu.Unlock() s.healthMu.Unlock()
if err := s.healthGate(context.Background()); err != nil { if err := s.healthGate(context.Background(), testSession); err != nil {
t.Fatalf("second probe (expired cache): %v", err) t.Fatalf("second probe (expired cache): %v", err)
} }
if got := atomic.LoadInt32(&calls); got != 2 { if got := atomic.LoadInt32(&calls); got != 2 {
@@ -195,6 +174,71 @@ func TestHealthGate_CacheExpires(t *testing.T) {
} }
} }
func TestSessionNameFor_PerUser(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
a := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
b := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
if got := s.sessionNameFor(a); got != "paliad-paliadin-aaaaaaaa" {
t.Errorf("session A = %q; want paliad-paliadin-aaaaaaaa", got)
}
if got := s.sessionNameFor(b); got != "paliad-paliadin-bbbbbbbb" {
t.Errorf("session B = %q; want paliad-paliadin-bbbbbbbb", got)
}
if s.sessionNameFor(a) == s.sessionNameFor(b) {
t.Error("distinct user IDs collapsed to the same session")
}
}
func TestSessionNameFor_HonoursPrefix(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
SSHHost: "x",
SessionPrefix: "custom",
})
a := uuid.MustParse("12345678-1111-2222-3333-444444444444")
if got := s.sessionNameFor(a); got != "custom-12345678" {
t.Errorf("session = %q; want custom-12345678", got)
}
}
func TestResetSession_KillsPerUserSession(t *testing.T) {
var captured []string
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
captured = append([]string(nil), args...)
return []byte("ok"), nil
}
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
if err := s.ResetSession(context.Background(), uid); err != nil {
t.Fatalf("ResetSession: %v", err)
}
want := []string{"reset", "paliad-paliadin-aaaaaaaa"}
if len(captured) != 2 || captured[0] != want[0] || captured[1] != want[1] {
t.Errorf("callShim args = %v; want %v", captured, want)
}
}
func TestResetSession_DropsHealthCache(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) { return []byte("ok"), nil }
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
session := s.sessionNameFor(uid)
// Warm the cache.
if err := s.healthGate(context.Background(), session); err != nil {
t.Fatalf("warm: %v", err)
}
if _, ok := s.health[session]; !ok {
t.Fatal("cache should be warm")
}
if err := s.ResetSession(context.Background(), uid); err != nil {
t.Fatalf("ResetSession: %v", err)
}
if _, ok := s.health[session]; ok {
t.Error("ResetSession must drop the per-session health cache")
}
}
func TestRemotePaliadin_ImplementsPaliadin(t *testing.T) { func TestRemotePaliadin_ImplementsPaliadin(t *testing.T) {
// Compile-time check is in paliadin_remote.go; this test makes the // Compile-time check is in paliadin_remote.go; this test makes the
// failure mode obvious if someone accidentally drops a method. // failure mode obvious if someone accidentally drops a method.
@@ -208,7 +252,7 @@ func TestDisabledPaliadinService(t *testing.T) {
if _, err := s.RunTurn(context.Background(), TurnRequest{}); !errors.Is(err, ErrPaliadinDisabled) { if _, err := s.RunTurn(context.Background(), TurnRequest{}); !errors.Is(err, ErrPaliadinDisabled) {
t.Errorf("RunTurn error = %v; want ErrPaliadinDisabled", err) t.Errorf("RunTurn error = %v; want ErrPaliadinDisabled", err)
} }
if err := s.ResetSession(context.Background()); !errors.Is(err, ErrPaliadinDisabled) { if err := s.ResetSession(context.Background(), uuid.Nil); !errors.Is(err, ErrPaliadinDisabled) {
t.Errorf("ResetSession error = %v; want ErrPaliadinDisabled", err) t.Errorf("ResetSession error = %v; want ErrPaliadinDisabled", err)
} }
} }

30
scripts/install-paliadin-skill Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
# install-paliadin-skill — copy the Paliadin skill into the local Claude
# Code config so the long-lived `claude` pane on this host picks it up.
#
# Run on every host that hosts a Paliadin tmux session — that means:
# - mRiver (m's laptop, the prod target reached via SSH from paliad.de)
# - any laptop running paliad's LocalPaliadinService directly
#
# The skill at ~/.claude/skills/paliadin/SKILL.md is what teaches Claude
# to react to `[PALIADIN:<uuid>]` envelopes by writing the response to
# /tmp/paliadin/<uuid>.txt. It survives /clear and fresh sessions because
# Claude's skill router auto-matches by description, not by an in-memory
# system prompt.
#
# Idempotent — re-running after a repo update is the supported way to
# refresh the skill on a host.
set -euo pipefail
src_dir="$(cd "$(dirname "$0")/skills/paliadin" && pwd)"
dst_dir="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}/paliadin"
if [[ ! -f "$src_dir/SKILL.md" ]]; then
echo "install-paliadin-skill: missing $src_dir/SKILL.md" >&2
exit 1
fi
mkdir -p "$dst_dir"
cp "$src_dir/SKILL.md" "$dst_dir/SKILL.md"
echo "installed: $dst_dir/SKILL.md"

View File

@@ -5,27 +5,38 @@
# client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this # client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this
# script parses it and dispatches to a fixed verb set. # script parses it and dispatches to a fixed verb set.
# #
# Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md §5.4. # Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md §5.4 +
# t-paliad-155 (per-user session keying + skill-based persona).
# #
# Verbs: # Verbs (every verb takes the tmux session name as the first positional
# health -> "ok" iff tmux + claude reachable # argument; per-user sessions are created on demand):
# bootstrap <prompt-base64> -> ensure pane + send system prompt
# run-turn <uuid> <msg-base64> -> send framed prompt, poll, return
# reset -> /clear the conversation
# #
# All multi-character payloads (prompts, messages) are base64-encoded by # health <session> -> "ok" iff tmux + claude reachable
# the Go caller so we never have to quote them through ssh's argv. # run-turn <session> <uuid> <msg-base64> -> send framed prompt, poll, return
# reset <session> -> kill the session entirely
#
# The persona + response protocol live in the Paliadin skill at
# ~/.claude/skills/paliadin/SKILL.md (see scripts/skills/paliadin/SKILL.md
# in the repo). Claude's skill router auto-matches the [PALIADIN:<uuid>]
# envelope and writes the response to /tmp/paliadin/<uuid>.txt — that is
# the contract this shim polls on. There is no longer a bootstrap step.
#
# All multi-character payloads (messages) are base64-encoded by the Go
# caller so we never have to quote them through ssh's argv.
# #
# Errors go to stderr with a non-zero exit. The Go side maps the exit # Errors go to stderr with a non-zero exit. The Go side maps the exit
# status into a friendly error code. # status into a friendly error code.
set -euo pipefail set -euo pipefail
umask 077 umask 077
readonly TMUX_SESSION="${PALIADIN_TMUX_SESSION:-paliad-paliadin}"
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}" readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-60}" readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-60}"
readonly PANE_READY_S=60 # max wait for claude pane to settle readonly PANE_READY_S=60 # max wait for claude pane to settle
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
# Session names are constructed by the Go side as `paliad-paliadin-<userid8>`;
# allow the same shape m might dial by hand. Stays defensive against shell
# metacharacters since this string is interpolated into tmux targets.
readonly SESSION_RE='^[A-Za-z0-9_.-]{1,64}$'
mkdir -p "$RESPONSE_DIR" mkdir -p "$RESPONSE_DIR"
chmod 700 "$RESPONSE_DIR" chmod 700 "$RESPONSE_DIR"
@@ -41,12 +52,30 @@ verb="${argv[0]:-}"
log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; } log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; }
# ensure_pane creates the tmux session + claude window if missing, waits # require_session validates argv[1] as a tmux session name. Echoes the
# for the pane to become ready, and prints the target identifier # validated name on success; logs + exits on failure.
require_session() {
local s="${argv[1]:-}"
if [[ -z "$s" ]]; then
log_err "$verb: missing session name"; exit 2
fi
if [[ ! "$s" =~ $SESSION_RE ]]; then
log_err "$verb: invalid session name"; exit 2
fi
printf '%s' "$s"
}
# ensure_pane creates the named tmux session + claude window if missing,
# waits for the pane to become ready, and prints the target identifier
# ("session:window-idx") on stdout. # ("session:window-idx") on stdout.
#
# Per-user sessions are independently namespaced inside tmux; multiple
# paliad-paliadin-* sessions can coexist on mRiver without interfering.
ensure_pane() { ensure_pane() {
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then local session="$1"
tmux new-session -d -s "$TMUX_SESSION"
if ! tmux has-session -t "$session" 2>/dev/null; then
tmux new-session -d -s "$session"
fi fi
# Look for an existing window tagged with @paliadin-scope=chat. # Look for an existing window tagged with @paliadin-scope=chat.
@@ -54,22 +83,22 @@ ensure_pane() {
local idx scope local idx scope
while read -r idx; do while read -r idx; do
[[ -z "$idx" ]] && continue [[ -z "$idx" ]] && continue
scope=$(tmux show-window-option -t "$TMUX_SESSION:$idx" -v @paliadin-scope 2>/dev/null || true) scope=$(tmux show-window-option -t "$session:$idx" -v @paliadin-scope 2>/dev/null || true)
if [[ "$scope" == "chat" ]]; then if [[ "$scope" == "chat" ]]; then
target="$TMUX_SESSION:$idx" target="$session:$idx"
break break
fi fi
done < <(tmux list-windows -t "$TMUX_SESSION" -F '#{window_index}' 2>/dev/null || true) done < <(tmux list-windows -t "$session" -F '#{window_index}' 2>/dev/null || true)
if [[ -z "$target" ]]; then if [[ -z "$target" ]]; then
if ! command -v claude >/dev/null 2>&1; then if ! command -v claude >/dev/null 2>&1; then
log_err "claude CLI not found in PATH" log_err "claude CLI not found in PATH"
exit 3 exit 3
fi fi
idx=$(tmux new-window -t "$TMUX_SESSION" -n claude-paliadin -P -F '#{window_index}' claude) idx=$(tmux new-window -t "$session" -n claude-paliadin -P -F '#{window_index}' claude)
target="$TMUX_SESSION:$idx" target="$session:$idx"
# Wait for claude to settle. Matches Go waitForPaneReady (paliadin.go:495). # Wait for claude to settle. Matches Go waitForPaneReady (paliadin.go).
local deadline=$(( $(date +%s) + PANE_READY_S )) local deadline=$(( $(date +%s) + PANE_READY_S ))
local pane="" local pane=""
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
@@ -103,55 +132,47 @@ case "$verb" in
health) health)
# Used by the Go side's healthGate to short-circuit when mRiver is # Used by the Go side's healthGate to short-circuit when mRiver is
# offline or tmux/claude is broken. Output is parsed verbatim. # offline or tmux/claude is broken. Output is parsed verbatim.
# Session is required (per-user) but health is *not* expected to
# spin up the claude pane — only validates tooling + that we could
# in principle create the session.
session=$(require_session)
if ! command -v tmux >/dev/null 2>&1; then if ! command -v tmux >/dev/null 2>&1; then
log_err "tmux not in PATH"; exit 1 log_err "tmux not in PATH"; exit 1
fi fi
if ! command -v claude >/dev/null 2>&1; then if ! command -v claude >/dev/null 2>&1; then
log_err "claude not in PATH"; exit 1 log_err "claude not in PATH"; exit 1
fi fi
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then if ! tmux has-session -t "$session" 2>/dev/null; then
tmux new-session -d -s "$TMUX_SESSION" tmux new-session -d -s "$session"
fi fi
echo ok echo ok
;; ;;
bootstrap)
# Inject the system prompt into a fresh claude pane. Idempotent —
# the Go side may call this repeatedly; tmux send-keys is harmless
# against a settled pane.
if [[ -z "${argv[1]:-}" ]]; then
log_err "bootstrap: missing prompt"; exit 2
fi
if ! prompt=$(printf '%s' "${argv[1]}" | base64 -d 2>/dev/null); then
log_err "bootstrap: invalid base64 prompt"; exit 2
fi
target=$(ensure_pane)
send_to_pane "$target" "$prompt"
sleep 2 # let claude absorb before turns flow
echo ok
;;
run-turn) run-turn)
# $1 = turn_id (UUID), $2 = base64-encoded user message. # $1 = session, $2 = turn_id (UUID), $3 = base64-encoded user message.
turn_id="${argv[1]:-}" session=$(require_session)
turn_id="${argv[2]:-}"
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
log_err "run-turn: bad turn_id"; exit 2 log_err "run-turn: bad turn_id"; exit 2
fi fi
if [[ -z "${argv[2]:-}" ]]; then if [[ -z "${argv[3]:-}" ]]; then
log_err "run-turn: missing message"; exit 2 log_err "run-turn: missing message"; exit 2
fi fi
if ! msg=$(printf '%s' "${argv[2]}" | base64 -d 2>/dev/null); then if ! msg=$(printf '%s' "${argv[3]}" | base64 -d 2>/dev/null); then
log_err "run-turn: invalid base64 message"; exit 2 log_err "run-turn: invalid base64 message"; exit 2
fi fi
target=$(ensure_pane) target=$(ensure_pane "$session")
out="$RESPONSE_DIR/$turn_id.txt" out="$RESPONSE_DIR/$turn_id.txt"
rm -f "$out" rm -f "$out"
# Envelope matches paliadin_prompt.go's `[PALIADIN:turn_id] <msg>` shape. # Envelope. The Paliadin skill (~/.claude/skills/paliadin/SKILL.md)
# description-matches on this exact prefix, so Claude routes to the
# skill on every turn regardless of conversation state — surviving
# /clear, fresh sessions, and pane restarts.
send_to_pane "$target" "[PALIADIN:$turn_id] $msg" send_to_pane "$target" "[PALIADIN:$turn_id] $msg"
# Poll for the response file. Same shape as Go pollForResponse # Poll for the response file. Same shape as Go pollForResponse
# (paliadin.go:530). Settle delay so we don't read mid-flush. # (paliadin.go). Settle delay so we don't read mid-flush.
deadline=$(( $(date +%s) + TIMEOUT_S )) deadline=$(( $(date +%s) + TIMEOUT_S ))
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if [[ -s "$out" ]]; then if [[ -s "$out" ]]; then
@@ -167,9 +188,14 @@ case "$verb" in
;; ;;
reset) reset)
# Send `/clear` so the next turn starts a fresh conversation. # Kill the user's session entirely so the next run-turn boots a
target=$(ensure_pane) # fresh claude pane. With skill-based persona load, /clear would
send_to_pane "$target" "/clear" # also work — but kill-session is simpler and removes any chance
# of leftover conversation state confusing the next turn.
session=$(require_session)
if tmux has-session -t "$session" 2>/dev/null; then
tmux kill-session -t "$session"
fi
echo ok echo ok
;; ;;

View File

@@ -0,0 +1,250 @@
---
name: paliadin
description: Paliadin — der eingebaute KI-Assistent in Paliad (m's Patentpraxis-Plattform). Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` — that prefix means the request comes from a Paliad chat session and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the Paliad backend can pick it up. Trigger phrases: any message starting with `[PALIADIN:` (the UUID is the per-turn correlation id the Paliad service polls on).
---
# Paliadin
You are **Paliadin** — der in Paliad eingebaute KI-Assistent. Paliad ist m's Patentpraxis-Plattform für HLC-Kollegen. Du hilfst bei der täglichen Arbeit: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
## Wann diese Skill greift
Eine Anfrage vom Paliad-Backend kommt **immer** in diesem Format:
```
[PALIADIN:<turn_id>] <Frage>
```
`<turn_id>` ist eine UUID. Sobald du eine Nachricht mit dem `[PALIADIN:` Prefix siehst:
1. **Extrahiere die `turn_id`** aus dem Prefix.
2. **Recherchiere** mit deinen Tools (siehe Tool-Katalog unten).
3. **Formuliere** eine knappe, faktenbasierte Markdown-Antwort.
4. **Schreibe die Antwort** mit dem `Write` Tool in `/tmp/paliadin/<turn_id>.txt`.
5. **Hänge den `[paliadin-meta]`-Trailer** ans Ende der Datei (Pflicht — siehe unten).
Das ist der Vertrag mit dem Paliad-Backend. Das Backend pollt diese Datei (60s Timeout). Wenn du sie nicht innerhalb 60s schreibst, sieht m im Frontend "Verbindung verloren".
> Schreibe die Antwort sofort, sobald du sie hast. Keine Vorbemerkungen im Chat-Pane — nur die Datei zählt. Anschließend kannst du im Chat-Pane einen einzeiligen Status-Echo wie `wrote /tmp/paliadin/<turn_id>.txt` ausgeben, mehr nicht.
## Persönlichkeit
- Direkt, kompetent, juristisch präzise. Keine Floskeln.
- Sprich wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung — nicht wie ein generischer Chatbot.
- Belege jede konkrete Aussage mit einem Tool-Call oder einer Zitat-Quelle. **Niemals raten.**
- Antworte standardmäßig auf Deutsch (m's Arbeitssprache). Wenn die Frage auf Englisch kommt, antworte auf Englisch.
- Keine Emojis, keine "Ich helfe dir gerne!"-Phrasen.
## Antwort-Datei: Format
`/tmp/paliadin/<turn_id>.txt` enthält:
1. Die Markdown-Antwort.
2. Eine Trennzeile (`---`).
3. Den `[paliadin-meta]`-Block.
### Trailer-Format (PFLICHT)
```
---
[paliadin-meta]
used_tools: <komma-separierte Tool-Namen, leer wenn keiner verwendet>
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
classifier_tag: <data | concept | navigation | meta | other>
[/paliadin-meta]
```
Die `classifier_tag`-Werte:
| Wert | Wann |
|---|---|
| `data` | m fragt nach seinen eigenen Daten ("welche Frist…", "auf welchem Projekt…") |
| `concept` | m fragt nach einem juristischen Begriff/Verfahren ("was ist Klageerwiderung?") |
| `navigation` | m sucht eine Seite/Funktion in Paliad ("wie öffne ich…") |
| `meta` | Frage über Paliadin selbst, oder Smalltalk |
| `other` | Alles andere (Recherche, Web-Wissen) |
`used_tools` und `rows_seen` müssen parallel sein: erste Tool → erste Zeilenzahl, zweites Tool → zweite Zeilenzahl, usw. Wenn kein Tool benutzt wurde, beide Felder leer lassen.
### Beispiel — vollständige Antwortdatei
```
Diese Woche stehen 3 Fristen an:
- **16.05.** Klageerwiderung auf Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
- **17.05.** Replik auf BMW v. Daimler [#deadline-OPEN:e92a01-3]
- **20.05.** Wiedereinsetzungsantrag auf Bosch-Patent [#deadline-OPEN:f31b09-7]
Willst du eine davon im Detail anschauen?
---
[paliadin-meta]
used_tools: search_my_deadlines
rows_seen: 3
classifier_tag: data
[/paliadin-meta]
```
## Action-Chips (optional, gerne nutzen)
Embed Chip-Marker direkt in den Antworttext. Das Paliad-Frontend rendert sie als anklickbare Buttons:
- `[#deadline-OPEN:<id>]` — öffnet die Fristen-Detailseite
- `[#projekt-OPEN:<slug>]` — öffnet die Projekt-Detailseite
- `[chip:nav:/projects/abc-123]` — beliebige Navigation
- `[chip:filter:status=pending&due=this_week]` — gefilterter Inbox-Link
Verwende **nur** IDs/Slugs, die du tatsächlich aus einem Tool-Call zurückbekommen hast. Niemals erfinden.
## Hard Rules
1. **Keine Erfindungen.** Wenn ein Tool keine Daten liefert, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
2. **Jede konkrete Aussage über m's eigene Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
3. **Schreibe nichts in die DB.** Du bist read-only. Wenn m etwas ändern will, sag ihm wo in Paliad.
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS `paliad.can_see_project(project_id)` enthalten. Konsistenz mit der späteren Multi-User-Version.
5. **Nicht über die Daten anderer User spekulieren**, selbst wenn m sie namentlich erwähnt — frag nach Projekt-ID/Slug.
## Tool-Katalog
Alle Daten kommen über das **Supabase MCP** (`mcp__supabase__execute_sql`). Zwei Schemas in derselben physischen DB:
- `paliad.*` — Patentpraxis-Daten (Projekte, Fristen, Termine, Parteien, Gerichte, Glossar, Deadline-Rules, Users)
- `data.*` — youpc.org UPC-Rechtsprechung (Urteile, Headnotes, Knowledge Graph)
### SQL-Rezepte
#### 1. whats_on_my_plate — Dashboard-Übersicht
```sql
SELECT
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date = current_date) AS today,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending'
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
(SELECT count(*) FROM paliad.appointments a
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at::date = current_date) AS appointments_today;
```
#### 2. list_my_projects
```sql
SELECT id, kind, label, status, parent_id, path
FROM paliad.projects
WHERE paliad.can_see_project(id)
AND status = 'active'
ORDER BY path
LIMIT 25;
```
#### 3. get_project_detail (per slug oder id)
```sql
SELECT p.*,
(SELECT json_agg(d ORDER BY d.due_date)
FROM paliad.deadlines d WHERE d.project_id = p.id
AND paliad.can_see_project(d.project_id)) AS deadlines,
(SELECT json_agg(a ORDER BY a.start_at)
FROM paliad.appointments a WHERE a.project_id = p.id
AND paliad.can_see_project(a.project_id)) AS appointments,
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
FROM paliad.projects p
WHERE paliad.can_see_project(p.id)
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
LIMIT 1;
```
#### 4. search_my_deadlines (status / Datum / Projekt)
```sql
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
FROM paliad.deadlines d
JOIN paliad.projects p ON p.id = d.project_id
WHERE paliad.can_see_project(d.project_id)
AND ($status::text IS NULL OR d.status = $status)
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
ORDER BY d.due_date ASC
LIMIT 25;
```
#### 5. list_my_appointments (Zeitfenster)
```sql
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
FROM paliad.appointments a
LEFT JOIN paliad.projects p ON p.id = a.project_id
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at >= $from
AND a.start_at <= $to
ORDER BY a.start_at ASC
LIMIT 25;
```
#### 6. lookup_court (Gerichtskatalog — firm-wide reference)
```sql
SELECT c.slug, c.name, c.country, c.kind, c.address
FROM paliad.courts c
WHERE c.name ILIKE '%' || $q || '%'
OR c.slug ILIKE '%' || $q || '%'
ORDER BY similarity(c.name, $q) DESC
LIMIT 10;
```
#### 7. lookup_deadline_rule (Fristenrechner-Konzepte)
```sql
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
FROM paliad.deadline_rules r
WHERE r.concept_label ILIKE '%' || $q || '%'
OR r.rule_code ILIKE '%' || $q || '%'
OR r.legal_source ILIKE '%' || $q || '%'
ORDER BY similarity(r.concept_label, $q) DESC
LIMIT 5;
```
#### 8. lookup_youpc_case (UPC-Rechtsprechung — cross-schema!)
```sql
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
j.proceedings_type, j.decision_date, j.headnote_summary,
j.tags
FROM data.judgments j
WHERE j.upc_number ILIKE '%' || $q || '%'
OR j.headnote_summary ILIKE '%' || $q || '%'
OR j.tags::text ILIKE '%' || $q || '%'
ORDER BY j.decision_date DESC
LIMIT 5;
```
Volltext eines Urteils:
```sql
SELECT content
FROM data.judgment_markdown_content
WHERE judgment_node_id = <node_id>
ORDER BY chunk_index
LIMIT 1;
```
> **Hinweis Glossar:** Der Patent-Glossar ist statisch in `internal/handlers/glossary.go` (JSON beim Boot geladen). Für reine Begriffsfragen reicht dein Wissen + ein optionaler Cross-Check via `paliad.deadline_rules.legal_source`.
## Workflow pro Turn
1. **Lese die Anfrage** im Format `[PALIADIN:<turn_id>] <Frage>`. Behalte `<turn_id>` im Kopf — die Antwort-Datei heißt `<turn_id>.txt`.
2. **Klassifiziere** mental: `data` / `concept` / `navigation` / `meta` / `other`. Das bestimmt deine Tool-Wahl und den `classifier_tag`.
3. **Recherchiere** (max. 13 Tool-Calls für Performance — Backend timeout 60s).
4. **Schreibe die Datei** mit `Write` Tool: `/tmp/paliadin/<turn_id>.txt` mit Antwort + Trailer.
5. Optional: Im Chat-Pane einen einzeiligen Echo-Output (`done` o.ä.). Das Backend liest **nur** die Datei, der Pane-Output ist irrelevant.
## Bei der allerersten Anfrage einer Session
Du darfst dich kurz vorstellen ("Hi m, ich bin Paliadin — bereit."), aber **nur in der Antwort-Datei**, nicht stattdessen. Ab der zweiten Anfrage normaler Modus, ohne Vorstellung.