diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4ee74fb..4b067b7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 `-` (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. diff --git a/cmd/server/main.go b/cmd/server/main.go index 8bb6bbe..0de1910 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/internal/handlers/paliadin.go b/internal/handlers/paliadin.go index c521bca..08dc4c8 100644 --- a/internal/handlers/paliadin.go +++ b/internal/handlers/paliadin.go @@ -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(), }) diff --git a/internal/services/paliadin.go b/internal/services/paliadin.go index afc9019..6669481 100644 --- a/internal/services/paliadin.go +++ b/internal/services/paliadin.go @@ -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 +// `-` (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 `-`. 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" { diff --git a/internal/services/paliadin_prompt.go b/internal/services/paliadin_prompt.go deleted file mode 100644 index 9f9f3f7..0000000 --- a/internal/services/paliadin_prompt.go +++ /dev/null @@ -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] `" + ` - -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: - rows_seen: - classifier_tag: - [/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 = '' OR p.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 = - 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. -`) -} diff --git a/internal/services/paliadin_remote.go b/internal/services/paliadin_remote.go index 36f34e0..d335c45 100644 --- a/internal/services/paliadin_remote.go +++ b/internal/services/paliadin_remote.go @@ -52,6 +52,7 @@ type RemotePaliadinConfig struct { SSHUser string // m SSHKeyPath string // /tmp/paliadin-id_ed25519- (chmod 600) KnownHostsPath string // /tmp/paliadin-known_hosts + SessionPrefix string // tmux session prefix; per-user session is "-" } // 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 ` 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 } diff --git a/internal/services/paliadin_remote_test.go b/internal/services/paliadin_remote_test.go index 98f204f..85dc7d7 100644 --- a/internal/services/paliadin_remote_test.go +++ b/internal/services/paliadin_remote_test.go @@ -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) } } diff --git a/scripts/install-paliadin-skill b/scripts/install-paliadin-skill new file mode 100755 index 0000000..3ff48cf --- /dev/null +++ b/scripts/install-paliadin-skill @@ -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:]` envelopes by writing the response to +# /tmp/paliadin/.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' diff --git a/scripts/paliadin-shim b/scripts/paliadin-shim index 5ab7667..dd1951a 100755 --- a/scripts/paliadin-shim +++ b/scripts/paliadin-shim @@ -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 -> ensure pane + send system prompt -# run-turn -> 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 -> "ok" iff tmux + claude reachable +# run-turn -> send framed prompt, poll, return +# reset -> 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:] +# envelope and writes the response to /tmp/paliadin/.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-`; +# 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] ` 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 ;; diff --git a/scripts/skills/paliadin/SKILL.md b/scripts/skills/paliadin/SKILL.md new file mode 100644 index 0000000..2ff2ef9 --- /dev/null +++ b/scripts/skills/paliadin/SKILL.md @@ -0,0 +1,97 @@ +--- +name: paliadin +description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/.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:] +``` + +Per turn: + +1. **Extract ``** from the prefix. +2. **Research** with tools (max 1–3 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/.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 + +``` + + +--- +[paliadin-meta] +used_tools: +rows_seen: +classifier_tag: +[/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:]` — öffnet Fristen-Detail +- `[#projekt-OPEN:]` — ö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. diff --git a/scripts/skills/paliadin/references/sql-recipes.md b/scripts/skills/paliadin/references/sql-recipes.md new file mode 100644 index 0000000..ebd352b --- /dev/null +++ b/scripts/skills/paliadin/references/sql-recipes.md @@ -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 = '' OR p.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 = + 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`.