Merge: t-paliad-155 — Paliadin skill-as-skill + per-user tmux session + project-MCP cwd + 120s timeout (skill at ~/.claude/skills/paliadin/SKILL.md replaces paliadin_prompt.go's keystroke-bootstrap; per-user session keying paliad-paliadin-<user_id_short>; shim spawns claude in /home/m/dev/paliad so project MCPs incl. supabase load; PALIADIN_TIMEOUT_S default 60→120s for cold-start safety; SKILL.md bans psql/curl fallbacks; install-paliadin-skill script for repo-as-source-of-truth; paliadin_prompt.go deprecated)

This commit is contained in:
m
2026-05-08 13:43:23 +02:00
11 changed files with 617 additions and 491 deletions

View File

@@ -47,7 +47,8 @@ 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. |
| `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…`). |
| `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_REMOTE_CWD` | shim env (default `/home/m/dev/paliad`) | Working directory `paliadin-shim` uses when spawning the long-lived `claude` pane on mRiver. Must be the paliad repo root so claude picks up `.mcp.json` (project-scoped Supabase MCP); without it, the SKILL.md SQL recipes have no DB tool. Set on mRiver only — paliad's Go side never reads this. |
| `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.

View File

@@ -189,9 +189,9 @@ func main() {
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
} 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")
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)
} else {
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
@@ -252,9 +252,10 @@ func main() {
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
cfg := services.RemotePaliadinConfig{
SSHHost: host,
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
SSHPort: 22022,
SSHHost: host,
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
SSHPort: 22022,
SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
}
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
n, err := strconv.Atoi(p)

View File

@@ -166,7 +166,9 @@ func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
}
// runPaliadinTurnAsync executes the turn and writes events into ch.
// Uses a 2-minute hard timeout independently of the originating request.
// Uses a 150 s hard timeout independently of the originating request,
// which leaves headroom over the shim's 120 s run-turn cap + SSH
// overhead (t-paliad-155: cold-start safety for skill + MCP discovery).
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
defer func() {
// Drain + close. The SSE handler reads until the channel closes.
@@ -182,7 +184,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
},
})
ctx, cancel := newDetachedContext(120 * time.Second)
ctx, cancel := newDetachedContext(150 * time.Second)
defer cancel()
result, err := paliadinSvc.RunTurn(ctx, req)
@@ -287,14 +289,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) {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner
ctx, cancel := newDetachedContext(10 * time.Second)
defer cancel()
if err := paliadinSvc.ResetSession(ctx); err != nil {
if err := paliadinSvc.ResetSession(ctx, uid); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"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.
type Paliadin interface {
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)
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, 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).
// Hardcoded single-user, single-tmux-window scope. Used on m's laptop;
// not deployed to prod (the Dokploy container has no `claude` CLI —
// see RemotePaliadinService for that path).
// Used on m's laptop; not deployed to prod (the Dokploy container has no
// `claude` CLI — 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 {
paliadinDB
tmuxSession string
responseDir string
sessionPrefix string
responseDir string
// Cached pane target ("session:window-idx") once the voice window is
// either discovered or created. Reset to "" if the pane dies.
mu sync.Mutex
paneTarget string
// Cached pane targets per user-session, keyed by tmux session name.
// A session entry maps to "session:window-idx"; cleared when the
// pane dies or ResetSession is called for that user.
mu sync.Mutex
panes map[string]string
// Single in-flight turn at a time. PoC scope — one user (m), serialised
// by a session-level mutex. Production v1 would queue / fan out.
// Single in-flight turn at a time across all users. PoC scope —
// claude CLI panes share the host's terminal noise; serialising
// keeps log output unambiguous.
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
}
// NewLocalPaliadinService wires the local-tmux PoC backend. Falls back
// to default tmux session + response dir when env vars are empty.
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *LocalPaliadinService {
if tmuxSession == "" {
tmuxSession = "paliad-paliadin"
// NewLocalPaliadinService wires the local-tmux PoC backend. The
// sessionPrefix arg is the prefix every per-user tmux session inherits —
// the actual session name is `<prefix>-<userid8>`. Falls back to
// defaults when env vars are empty.
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, sessionPrefix, responseDir string) *LocalPaliadinService {
if sessionPrefix == "" {
sessionPrefix = "paliad-paliadin"
}
if responseDir == "" {
responseDir = "/tmp/paliadin"
}
return &LocalPaliadinService{
paliadinDB: paliadinDB{db: db, users: users},
tmuxSession: tmuxSession,
responseDir: responseDir,
paliadinDB: paliadinDB{db: db, users: users},
sessionPrefix: sessionPrefix,
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.
type PaliadinTurn struct {
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)
}
// Ensure tmux session + Claude pane.
target, err := s.ensurePane(ctx)
// Ensure tmux session + Claude pane (per-user — keyed off UserID).
target, err := s.ensurePane(ctx, req.UserID)
if err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
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)
}
// Send the framed prompt. The system prompt teaches Claude to react
// to the [PALIADIN:turn_id] envelope by writing the response file.
// Send the framed prompt. The Paliadin skill at
// ~/.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))
if err := s.sendToPane(ctx, target, envelope); err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
@@ -260,20 +286,25 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
}, nil
}
// ResetSession sends `/clear` to the Claude pane so the next turn starts
// from a clean conversation. Used by the "New conversation" button.
func (s *LocalPaliadinService) ResetSession(ctx context.Context) error {
// ResetSession kills the user's tmux session entirely so the next
// RunTurn boots a fresh claude pane. With skill-based persona load
// (~/.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()
target := s.paneTarget
delete(s.panes, session)
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
}
if err := s.sendToPane(ctx, target, "/clear"); err != nil {
return err
}
return nil
return runTmux(ctx, "kill-session", "-t", session)
}
// 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
// Claude pane, creating both session and window if missing.
func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
// Claude pane for this user, creating both session and window if
// 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()
defer s.mu.Unlock()
// Cheap path: if we have a cached target and it's still alive, reuse.
if s.paneTarget != "" && s.paneAlive(ctx, s.paneTarget) {
return s.paneTarget, nil
// Cheap path: cached target still alive? Reuse.
if cached, ok := s.panes[session]; ok && cached != "" && s.paneAlive(ctx, cached) {
return cached, nil
}
// 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.
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)
}
}
// Look for an existing window tagged with @paliadin-scope=chat.
if existing := s.findChatWindow(ctx); existing != "" {
s.paneTarget = existing
if existing := s.findChatWindow(ctx, session); existing != "" {
s.panes[session] = existing
return existing, nil
}
// No window — create one running `claude` in a fresh pane. Must be
// interactive: claude reads stdin, so the tmux pane behaves like a
// 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",
"-P", "-F", "#{window_index}",
"claude")
@@ -462,7 +499,7 @@ func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
return "", fmt.Errorf("new-window claude: %w", err)
}
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
// 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, "@fix-name", "claude-paliadin")
// Send the bootstrap system prompt so Claude knows who it is and how
// 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
s.panes[session] = target
return target, nil
}
func (s *LocalPaliadinService) findChatWindow(ctx context.Context) string {
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
func (s *LocalPaliadinService) findChatWindow(ctx context.Context, session string) string {
out, err := runTmuxOut(ctx, "list-windows", "-t", session,
"-F", "#{window_index}")
if err != nil {
return ""
}
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",
"-t", target, "-v", "@paliadin-scope")
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
SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600)
KnownHostsPath string // /tmp/paliadin-known_hosts
SessionPrefix string // tmux session prefix; per-user session is "<prefix>-<userid8>"
}
// RemotePaliadinService implements Paliadin against a remote
@@ -60,27 +61,30 @@ type RemotePaliadinService struct {
paliadinDB
cfg RemotePaliadinConfig
// Single in-flight turn. mRiver's claude pane is single-user; we
// serialise turns the same way LocalPaliadinService does.
// Serialise turns across all users. mRiver's host has finite tmux
// concurrency anyway, and Paliadin turns are short. Per-user
// fan-out can ship in v2 if it ever bottlenecks.
turnMu sync.Mutex
// Health-check cache. Avoids probing mRiver on every turn — once
// the cache is warm, RunTurn skips the probe for 10 seconds.
healthMu sync.Mutex
healthOK bool
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
// Health-check cache, keyed by per-user session name. Avoids
// probing mRiver on every turn — once a session's cache is warm,
// RunTurn skips the probe for 10 seconds.
healthMu sync.Mutex
health map[string]healthCacheEntry
// Hook for tests — when non-nil, callShim delegates here instead
// of exec'ing ssh. Production code never sets this.
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
// PALIADIN_REMOTE_HOST is set in the environment; the constructor does
// 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 == "" {
cfg.SSHUser = "m"
}
if cfg.SessionPrefix == "" {
cfg.SessionPrefix = "paliad-paliadin"
}
return &RemotePaliadinService{
paliadinDB: paliadinDB{db: db, users: users},
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
// audit-row contract as LocalPaliadinService: write the row first, run
// 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)
}
session := s.sessionNameFor(req.UserID)
// 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.
if err := s.healthGate(ctx); err != nil {
// 10 s per session so a fast back-to-back chat doesn't probe every
// time.
if err := s.healthGate(ctx, session); err != nil {
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
return nil, err
}
// Lazy bootstrap — first turn after a paliad restart sends the
// system prompt; subsequent turns skip.
if err := s.ensureBootstrapped(ctx); err != nil {
_ = s.markTurnError(ctx, turnID, "bootstrap_failed")
return nil, err
}
// Persona + response protocol live in the Paliadin skill at
// ~/.claude/skills/paliadin/SKILL.md on mRiver. Claude's skill
// router auto-matches the [PALIADIN: envelope so no in-process
// bootstrap (system-prompt-via-tmux-keystroke) is needed any more.
msg := sanitiseForTmux(req.UserMessage)
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 {
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
return nil, err
@@ -165,55 +186,50 @@ func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
}, nil
}
// ResetSession sends `/clear` to the remote claude pane.
func (s *RemotePaliadinService) ResetSession(ctx context.Context) error {
if _, err := s.callShim(ctx, "reset"); err != nil {
return fmt.Errorf("paliadin: reset: %w", err)
// ResetSession kills the user's tmux session on mRiver entirely so the
// next RunTurn boots a fresh claude pane. Skill-based persona load
// means the new pane re-acquires the Paliadin protocol contract on
// 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
}
// healthGate runs the shim's `health` verb at most once per 10 s.
// Returns ErrMRiverUnreachable wrapping the underlying error on miss.
func (s *RemotePaliadinService) healthGate(ctx context.Context) error {
// healthGate runs the shim's `health <session>` verb at most once per
// 10 s per session. Returns ErrMRiverUnreachable wrapping the
// underlying error on miss.
func (s *RemotePaliadinService) healthGate(ctx context.Context, session string) error {
s.healthMu.Lock()
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
}
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := s.callShim(probeCtx, "health")
s.healthCheckedAt = time.Now()
out, err := s.callShim(probeCtx, "health", session)
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)
}
if strings.TrimSpace(string(out)) != "ok" {
s.healthOK = false
delete(s.health, session)
return fmt.Errorf("%w: shim returned %q", ErrMRiverUnreachable, string(out))
}
s.healthOK = true
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
s.health[session] = healthCacheEntry{ok: true, checkedAt: time.Now()}
return nil
}
@@ -245,8 +261,9 @@ func (s *RemotePaliadinService) callShim(ctx context.Context, args ...string) ([
}
sshArgs = append(sshArgs, args...)
// Shim's run-turn timeout is 60 s; +10 s gives SSH some overhead.
c, cancel := context.WithTimeout(ctx, 70*time.Second)
// Shim's run-turn timeout is 120 s (cold start = claude boot + skill
// load + MCP discovery + first reasoning); +10 s gives SSH overhead.
c, cancel := context.WithTimeout(ctx, 130*time.Second)
defer cancel()
cmd := exec.CommandContext(c, "ssh", sshArgs...)
@@ -309,7 +326,7 @@ func (s *DisabledPaliadinService) RunTurn(ctx context.Context, req TurnRequest)
return nil, ErrPaliadinDisabled
}
func (s *DisabledPaliadinService) ResetSession(ctx context.Context) error {
func (s *DisabledPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
return ErrPaliadinDisabled
}

View File

@@ -8,8 +8,15 @@ import (
"sync/atomic"
"testing"
"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
// 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
@@ -77,13 +84,13 @@ func TestHealthGate_CachesOnSuccess(t *testing.T) {
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
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)
}
return []byte("ok\n"), nil
}
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)
}
}
@@ -100,7 +107,7 @@ func TestHealthGate_RetriesAfterFailure(t *testing.T) {
return nil, errors.New("ssh: Connection refused")
}
for i := 0; i < 3; i++ {
err := s.healthGate(context.Background())
err := s.healthGate(context.Background(), testSession)
if !errors.Is(err, ErrMRiverUnreachable) {
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) {
return []byte("not-ok"), nil
}
err := s.healthGate(context.Background())
err := s.healthGate(context.Background(), testSession)
if !errors.Is(err, ErrMRiverUnreachable) {
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
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
atomic.AddInt32(&calls, 1)
if len(args) != 2 || args[0] != "bootstrap" {
t.Errorf("unexpected callShim args: %v", args)
}
// args[1] is the base64'd system prompt — no need to decode in
// the test; just sanity-check it isn't trivially empty.
if len(args[1]) < 100 {
t.Errorf("bootstrap prompt suspiciously short: %d bytes", len(args[1]))
}
return []byte("ok\n"), nil
return []byte("ok"), nil
}
for i := 0; i < 3; i++ {
if err := s.ensureBootstrapped(context.Background()); err != nil {
t.Fatalf("ensureBootstrapped iteration %d: %v", i, err)
}
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
t.Fatalf("session A first probe: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("expected 1 callShim call (bootstrap is one-shot); got %d", got)
if err := s.healthGate(context.Background(), "paliad-paliadin-bbbbbbbb"); err != nil {
t.Fatalf("session B first probe: %v", err)
}
}
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 err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
t.Fatalf("session A second probe: %v", err)
}
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)
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)
}
// Force the cached timestamp to expire.
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()
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)
}
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) {
// Compile-time check is in paliadin_remote.go; this test makes the
// 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) {
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)
}
}

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

@@ -0,0 +1,37 @@
#!/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"
# Mirror the entire skill tree (SKILL.md + references/), and clear out
# any stale auxiliary files left from a previous shape.
rm -rf "$dst_dir/references"
cp "$src_dir/SKILL.md" "$dst_dir/SKILL.md"
if [[ -d "$src_dir/references" ]]; then
cp -R "$src_dir/references" "$dst_dir/references"
fi
echo "installed: $dst_dir/"
find "$dst_dir" -type f -printf ' %P\n'

View File

@@ -5,27 +5,43 @@
# client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this
# 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:
# health -> "ok" iff tmux + claude reachable
# bootstrap <prompt-base64> -> ensure pane + send system prompt
# run-turn <uuid> <msg-base64> -> send framed prompt, poll, return
# reset -> /clear the conversation
# Verbs (every verb takes the tmux session name as the first positional
# argument; per-user sessions are created on demand):
#
# All multi-character payloads (prompts, messages) are base64-encoded by
# the Go caller so we never have to quote them through ssh's argv.
# health <session> -> "ok" iff tmux + claude reachable
# 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
# status into a friendly error code.
set -euo pipefail
umask 077
readonly TMUX_SESSION="${PALIADIN_TMUX_SESSION:-paliad-paliadin}"
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-60}"
readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-120}"
# Working directory for the claude pane. Must be the paliad repo root so
# claude picks up .mcp.json (project-scoped Supabase MCP) — without it,
# the SKILL.md SQL recipes fail with no DB tool. Override via env var if
# the repo lives elsewhere on this host.
readonly CLAUDE_CWD="${PALIADIN_REMOTE_CWD:-/home/m/dev/paliad}"
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}$'
# 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"
chmod 700 "$RESPONSE_DIR"
@@ -41,12 +57,30 @@ verb="${argv[0]:-}"
log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; }
# ensure_pane creates the tmux session + claude window if missing, waits
# for the pane to become ready, and prints the target identifier
# require_session validates argv[1] as a tmux session name. Echoes the
# 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.
#
# Per-user sessions are independently namespaced inside tmux; multiple
# paliad-paliadin-* sessions can coexist on mRiver without interfering.
ensure_pane() {
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
tmux new-session -d -s "$TMUX_SESSION"
local session="$1"
if ! tmux has-session -t "$session" 2>/dev/null; then
tmux new-session -d -s "$session"
fi
# Look for an existing window tagged with @paliadin-scope=chat.
@@ -54,22 +88,26 @@ ensure_pane() {
local idx scope
while read -r idx; do
[[ -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
target="$TMUX_SESSION:$idx"
target="$session:$idx"
break
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 ! command -v claude >/dev/null 2>&1; then
log_err "claude CLI not found in PATH"
exit 3
fi
idx=$(tmux new-window -t "$TMUX_SESSION" -n claude-paliadin -P -F '#{window_index}' claude)
target="$TMUX_SESSION:$idx"
if [[ ! -d "$CLAUDE_CWD" ]]; then
log_err "claude cwd $CLAUDE_CWD does not exist — set PALIADIN_REMOTE_CWD"
exit 3
fi
idx=$(tmux new-window -c "$CLAUDE_CWD" -t "$session" -n claude-paliadin -P -F '#{window_index}' claude)
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 pane=""
while [[ $(date +%s) -lt $deadline ]]; do
@@ -103,55 +141,47 @@ case "$verb" in
health)
# Used by the Go side's healthGate to short-circuit when mRiver is
# 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
log_err "tmux not in PATH"; exit 1
fi
if ! command -v claude >/dev/null 2>&1; then
log_err "claude not in PATH"; exit 1
fi
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
tmux new-session -d -s "$TMUX_SESSION"
if ! tmux has-session -t "$session" 2>/dev/null; then
tmux new-session -d -s "$session"
fi
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)
# $1 = turn_id (UUID), $2 = base64-encoded user message.
turn_id="${argv[1]:-}"
# $1 = session, $2 = turn_id (UUID), $3 = base64-encoded user message.
session=$(require_session)
turn_id="${argv[2]:-}"
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
log_err "run-turn: bad turn_id"; exit 2
fi
if [[ -z "${argv[2]:-}" ]]; then
if [[ -z "${argv[3]:-}" ]]; then
log_err "run-turn: missing message"; exit 2
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
fi
target=$(ensure_pane)
target=$(ensure_pane "$session")
out="$RESPONSE_DIR/$turn_id.txt"
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"
# 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 ))
while [[ $(date +%s) -lt $deadline ]]; do
if [[ -s "$out" ]]; then
@@ -167,9 +197,14 @@ case "$verb" in
;;
reset)
# Send `/clear` so the next turn starts a fresh conversation.
target=$(ensure_pane)
send_to_pane "$target" "/clear"
# Kill the user's session entirely so the next run-turn boots a
# fresh claude pane. With skill-based persona load, /clear would
# 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
;;

View File

@@ -0,0 +1,97 @@
---
name: paliadin
description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the polling Go service can return it to the user. Trigger on the literal `[PALIADIN:` prefix, even when m's question is short ("Hey", "wer bin ich?") and looks like normal chat — the prefix is the contract, not the question content. Persona: m's Patentpraxis-Plattform-Assistent — terse, juristisch präzise German, no emojis, every concrete claim backed by a tool-call.
---
# Paliadin
You are the in-app AI assistant inside **Paliad**, m's Patentpraxis-Plattform für HLC-Kollegen. You help with daily patent-practice work: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
## Quick start — one turn
Every Paliad request looks like:
```
[PALIADIN:<turn_id>] <Frage>
```
Per turn:
1. **Extract `<turn_id>`** from the prefix.
2. **Research** with tools (max 13 calls — backend timeout is 60s). See [references/sql-recipes.md](references/sql-recipes.md) **before any project/deadline/court/glossary/UPC lookup**.
3. **Write the file** with `Write("/tmp/paliadin/<turn_id>.txt", …)` containing the Markdown answer + `[paliadin-meta]` trailer.
4. (Optional) one-line echo in the chat pane (`done`). The backend reads only the file.
> Skip every greeting / preamble in the chat pane. The file is the user-visible artefact; everything else is irrelevant.
## Persona
- Direkt, kompetent, juristisch präzise — wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung.
- Default Deutsch (m's Arbeitssprache); auf englische Frage englisch antworten.
- Keine Floskeln, keine Emojis, kein "Ich helfe dir gerne!".
## Response-file format
```
<Markdown-Antwort>
---
[paliadin-meta]
used_tools: <komma-separierte Tool-Namen, leer wenn keiner>
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
classifier_tag: <data | concept | navigation | meta | other>
[/paliadin-meta]
```
`classifier_tag` — pick one:
| Wert | Wann |
|---|---|
| `data` | m fragt nach seinen eigenen Daten ("welche Frist…") |
| `concept` | juristischer Begriff/Verfahren ("was ist Klageerwiderung?") |
| `navigation` | Paliad-Seite/Funktion suchen ("wie öffne ich…") |
| `meta` | Frage über Paliadin selbst, oder Smalltalk |
| `other` | Web-Wissen, sonstige Recherche |
`used_tools` und `rows_seen` müssen parallel sein (Tool-N → Rows-N). Beide leer, wenn kein Tool benutzt.
## Action-Chips (optional)
Direkt im Antworttext einbetten — Paliad-Frontend rendert sie als Buttons:
- `[#deadline-OPEN:<id>]` — öffnet Fristen-Detail
- `[#projekt-OPEN:<slug>]` — öffnet Projekt-Detail
- `[chip:nav:/projects/abc-123]` — beliebige Navigation
- `[chip:filter:status=pending&due=this_week]` — gefilterter Inbox-Link
Nur IDs/Slugs benutzen, die du tatsächlich aus einem Tool-Call hast. **Niemals erfinden.**
## Hard rules
1. **Keine Erfindungen.** Liefert ein Tool nichts, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
2. **Jede konkrete Aussage über m's Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
3. **Read-only.** Schreibe nichts in die DB. Wenn m etwas ändern will, sag 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.
5. **Nicht über andere User spekulieren** — frag nach Projekt-ID/Slug, selbst wenn m sie namentlich erwähnt.
6. **Niemals auf `psql`, `curl PostgREST`, `nix-shell` oder andere DB-Fallbacks ausweichen.** Die einzig zulässige DB-Quelle ist `mcp__supabase__execute_sql` (project-scoped MCP). Wenn dieser Tool-Aufruf nicht verfügbar ist, schreibe sofort: *"DB nicht erreichbar — bitte paliad neu deployen oder PALIADIN_REMOTE_CWD prüfen."* mit `classifier_tag: meta`. Niemals 60+ Sekunden im Fallback-Tanz verbringen — der Backend-Timeout schlägt sonst zu, bevor du eine Antwort schreibst.
## Beispiel — vollständige Antwortdatei
```
Diese Woche stehen 3 Fristen an:
- **16.05.** Klageerwiderung Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
- **17.05.** Replik BMW v. Daimler [#deadline-OPEN:e92a01-3]
- **20.05.** Wiedereinsetzung Bosch-Patent [#deadline-OPEN:f31b09-7]
---
[paliadin-meta]
used_tools: search_my_deadlines
rows_seen: 3
classifier_tag: data
[/paliadin-meta]
```
## Allererste Anfrage einer Session
Eine kurze Vorstellung in der **Antwort-Datei** ist erlaubt ("Hi m, ich bin Paliadin — bereit."), nie statt der Datei. Ab Turn 2 normaler Modus.

View File

@@ -0,0 +1,134 @@
# SQL recipes — Paliadin tool catalogue
Read this file **before any project / deadline / appointment / court / glossary / deadline-rule / UPC-judgment lookup**. Every query goes through the Supabase MCP via `mcp__supabase__execute_sql`. Two schemas in the same physical DB:
- `paliad.*` — Patentpraxis-Daten (projects, deadlines, appointments, parties, courts, deadline_rules, users)
- `data.*` — youpc.org UPC case law (judgments, headnotes, knowledge graph)
Every project-scoped query MUST include `paliad.can_see_project(project_id)` — even when m is global_admin (see SKILL.md rule 4).
## 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 (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 (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;
```
## Glossar — keine SQL-Tabelle
Der Patent-Glossar lebt statisch in `internal/handlers/glossary.go` (JSON beim Boot geladen). Für reine Begriffsfragen reicht dein Wissen + optional Cross-Check via `paliad.deadline_rules.legal_source`.