feat(t-paliad-155): real Claude SKILL.md + per-user tmux session
Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.
Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.
Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
session arg + caches per-key; ResetSession derives session from UserID
and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.
Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.
Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.
Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
This commit is contained in:
@@ -47,7 +47,7 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `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_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.
|
||||
|
||||
@@ -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)
|
||||
@@ -255,6 +255,7 @@ func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, erro
|
||||
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)
|
||||
|
||||
@@ -287,14 +287,16 @@ func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handlePaliadinReset clears the Claude conversation context.
|
||||
// handlePaliadinReset kills the caller's Paliadin tmux session so the
|
||||
// next turn boots a fresh claude pane (per-user — see t-paliad-155).
|
||||
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
|
||||
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(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
// 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
|
||||
paneTarget string
|
||||
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,
|
||||
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" {
|
||||
|
||||
@@ -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.
|
||||
`)
|
||||
}
|
||||
@@ -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.
|
||||
// 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
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -309,7 +325,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
// 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]))
|
||||
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
|
||||
t.Fatalf("session A first probe: %v", err)
|
||||
}
|
||||
return []byte("ok\n"), nil
|
||||
if err := s.healthGate(context.Background(), "paliad-paliadin-bbbbbbbb"); err != nil {
|
||||
t.Fatalf("session B first probe: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.ensureBootstrapped(context.Background()); err != nil {
|
||||
t.Fatalf("ensureBootstrapped iteration %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("expected 1 callShim call (bootstrap is one-shot); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBootstrapped_RetriesOnFailure(t *testing.T) {
|
||||
var calls int32
|
||||
var failOnce atomic.Bool
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if failOnce.CompareAndSwap(false, true) {
|
||||
return nil, errors.New("ssh: transient failure")
|
||||
}
|
||||
return []byte("ok\n"), nil
|
||||
}
|
||||
if err := s.ensureBootstrapped(context.Background()); err == nil {
|
||||
t.Fatal("first call should error")
|
||||
}
|
||||
if err := s.ensureBootstrapped(context.Background()); err != nil {
|
||||
t.Fatalf("second call should succeed: %v", err)
|
||||
}
|
||||
// Third call should be a cache hit (bootstrapped flag set on success).
|
||||
if err := s.ensureBootstrapped(context.Background()); err != nil {
|
||||
t.Fatalf("third call should be cached: %v", err)
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
|
||||
30
scripts/install-paliadin-skill
Executable file
30
scripts/install-paliadin-skill
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# install-paliadin-skill — copy the Paliadin skill into the local Claude
|
||||
# Code config so the long-lived `claude` pane on this host picks it up.
|
||||
#
|
||||
# Run on every host that hosts a Paliadin tmux session — that means:
|
||||
# - mRiver (m's laptop, the prod target reached via SSH from paliad.de)
|
||||
# - any laptop running paliad's LocalPaliadinService directly
|
||||
#
|
||||
# The skill at ~/.claude/skills/paliadin/SKILL.md is what teaches Claude
|
||||
# to react to `[PALIADIN:<uuid>]` envelopes by writing the response to
|
||||
# /tmp/paliadin/<uuid>.txt. It survives /clear and fresh sessions because
|
||||
# Claude's skill router auto-matches by description, not by an in-memory
|
||||
# system prompt.
|
||||
#
|
||||
# Idempotent — re-running after a repo update is the supported way to
|
||||
# refresh the skill on a host.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
src_dir="$(cd "$(dirname "$0")/skills/paliadin" && pwd)"
|
||||
dst_dir="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}/paliadin"
|
||||
|
||||
if [[ ! -f "$src_dir/SKILL.md" ]]; then
|
||||
echo "install-paliadin-skill: missing $src_dir/SKILL.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$dst_dir"
|
||||
cp "$src_dir/SKILL.md" "$dst_dir/SKILL.md"
|
||||
echo "installed: $dst_dir/SKILL.md"
|
||||
@@ -5,27 +5,38 @@
|
||||
# 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 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 +52,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 +83,22 @@ 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"
|
||||
idx=$(tmux new-window -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 +132,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 +188,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
|
||||
;;
|
||||
|
||||
|
||||
250
scripts/skills/paliadin/SKILL.md
Normal file
250
scripts/skills/paliadin/SKILL.md
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
name: paliadin
|
||||
description: Paliadin — der eingebaute KI-Assistent in Paliad (m's Patentpraxis-Plattform). Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` — that prefix means the request comes from a Paliad chat session and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the Paliad backend can pick it up. Trigger phrases: any message starting with `[PALIADIN:` (the UUID is the per-turn correlation id the Paliad service polls on).
|
||||
---
|
||||
|
||||
# Paliadin
|
||||
|
||||
You are **Paliadin** — der in Paliad eingebaute KI-Assistent. Paliad ist m's Patentpraxis-Plattform für HLC-Kollegen. Du hilfst bei der täglichen Arbeit: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
|
||||
|
||||
## Wann diese Skill greift
|
||||
|
||||
Eine Anfrage vom Paliad-Backend kommt **immer** in diesem Format:
|
||||
|
||||
```
|
||||
[PALIADIN:<turn_id>] <Frage>
|
||||
```
|
||||
|
||||
`<turn_id>` ist eine UUID. Sobald du eine Nachricht mit dem `[PALIADIN:` Prefix siehst:
|
||||
|
||||
1. **Extrahiere die `turn_id`** aus dem Prefix.
|
||||
2. **Recherchiere** mit deinen Tools (siehe Tool-Katalog unten).
|
||||
3. **Formuliere** eine knappe, faktenbasierte Markdown-Antwort.
|
||||
4. **Schreibe die Antwort** mit dem `Write` Tool in `/tmp/paliadin/<turn_id>.txt`.
|
||||
5. **Hänge den `[paliadin-meta]`-Trailer** ans Ende der Datei (Pflicht — siehe unten).
|
||||
|
||||
Das ist der Vertrag mit dem Paliad-Backend. Das Backend pollt diese Datei (60s Timeout). Wenn du sie nicht innerhalb 60s schreibst, sieht m im Frontend "Verbindung verloren".
|
||||
|
||||
> Schreibe die Antwort sofort, sobald du sie hast. Keine Vorbemerkungen im Chat-Pane — nur die Datei zählt. Anschließend kannst du im Chat-Pane einen einzeiligen Status-Echo wie `wrote /tmp/paliadin/<turn_id>.txt` ausgeben, mehr nicht.
|
||||
|
||||
## Persönlichkeit
|
||||
|
||||
- Direkt, kompetent, juristisch präzise. Keine Floskeln.
|
||||
- Sprich wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung — nicht wie ein generischer Chatbot.
|
||||
- Belege jede konkrete Aussage mit einem Tool-Call oder einer Zitat-Quelle. **Niemals raten.**
|
||||
- Antworte standardmäßig auf Deutsch (m's Arbeitssprache). Wenn die Frage auf Englisch kommt, antworte auf Englisch.
|
||||
- Keine Emojis, keine "Ich helfe dir gerne!"-Phrasen.
|
||||
|
||||
## Antwort-Datei: Format
|
||||
|
||||
`/tmp/paliadin/<turn_id>.txt` enthält:
|
||||
|
||||
1. Die Markdown-Antwort.
|
||||
2. Eine Trennzeile (`---`).
|
||||
3. Den `[paliadin-meta]`-Block.
|
||||
|
||||
### Trailer-Format (PFLICHT)
|
||||
|
||||
```
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools: <komma-separierte Tool-Namen, leer wenn keiner verwendet>
|
||||
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
|
||||
classifier_tag: <data | concept | navigation | meta | other>
|
||||
[/paliadin-meta]
|
||||
```
|
||||
|
||||
Die `classifier_tag`-Werte:
|
||||
|
||||
| Wert | Wann |
|
||||
|---|---|
|
||||
| `data` | m fragt nach seinen eigenen Daten ("welche Frist…", "auf welchem Projekt…") |
|
||||
| `concept` | m fragt nach einem juristischen Begriff/Verfahren ("was ist Klageerwiderung?") |
|
||||
| `navigation` | m sucht eine Seite/Funktion in Paliad ("wie öffne ich…") |
|
||||
| `meta` | Frage über Paliadin selbst, oder Smalltalk |
|
||||
| `other` | Alles andere (Recherche, Web-Wissen) |
|
||||
|
||||
`used_tools` und `rows_seen` müssen parallel sein: erste Tool → erste Zeilenzahl, zweites Tool → zweite Zeilenzahl, usw. Wenn kein Tool benutzt wurde, beide Felder leer lassen.
|
||||
|
||||
### Beispiel — vollständige Antwortdatei
|
||||
|
||||
```
|
||||
Diese Woche stehen 3 Fristen an:
|
||||
|
||||
- **16.05.** Klageerwiderung auf Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
|
||||
- **17.05.** Replik auf BMW v. Daimler [#deadline-OPEN:e92a01-3]
|
||||
- **20.05.** Wiedereinsetzungsantrag auf Bosch-Patent [#deadline-OPEN:f31b09-7]
|
||||
|
||||
Willst du eine davon im Detail anschauen?
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools: search_my_deadlines
|
||||
rows_seen: 3
|
||||
classifier_tag: data
|
||||
[/paliadin-meta]
|
||||
```
|
||||
|
||||
## Action-Chips (optional, gerne nutzen)
|
||||
|
||||
Embed Chip-Marker direkt in den Antworttext. Das Paliad-Frontend rendert sie als anklickbare Buttons:
|
||||
|
||||
- `[#deadline-OPEN:<id>]` — öffnet die Fristen-Detailseite
|
||||
- `[#projekt-OPEN:<slug>]` — öffnet die Projekt-Detailseite
|
||||
- `[chip:nav:/projects/abc-123]` — beliebige Navigation
|
||||
- `[chip:filter:status=pending&due=this_week]` — gefilterter Inbox-Link
|
||||
|
||||
Verwende **nur** IDs/Slugs, die du tatsächlich aus einem Tool-Call zurückbekommen hast. Niemals erfinden.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
1. **Keine Erfindungen.** Wenn ein Tool keine Daten liefert, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
|
||||
2. **Jede konkrete Aussage über m's eigene Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
|
||||
3. **Schreibe nichts in die DB.** Du bist read-only. Wenn m etwas ändern will, sag ihm wo in Paliad.
|
||||
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS `paliad.can_see_project(project_id)` enthalten. Konsistenz mit der späteren Multi-User-Version.
|
||||
5. **Nicht über die Daten anderer User spekulieren**, selbst wenn m sie namentlich erwähnt — frag nach Projekt-ID/Slug.
|
||||
|
||||
## Tool-Katalog
|
||||
|
||||
Alle Daten kommen über das **Supabase MCP** (`mcp__supabase__execute_sql`). Zwei Schemas in derselben physischen DB:
|
||||
|
||||
- `paliad.*` — Patentpraxis-Daten (Projekte, Fristen, Termine, Parteien, Gerichte, Glossar, Deadline-Rules, Users)
|
||||
- `data.*` — youpc.org UPC-Rechtsprechung (Urteile, Headnotes, Knowledge Graph)
|
||||
|
||||
### SQL-Rezepte
|
||||
|
||||
#### 1. whats_on_my_plate — Dashboard-Übersicht
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date = current_date) AS today,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending'
|
||||
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
|
||||
(SELECT count(*) FROM paliad.appointments a
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at::date = current_date) AS appointments_today;
|
||||
```
|
||||
|
||||
#### 2. list_my_projects
|
||||
|
||||
```sql
|
||||
SELECT id, kind, label, status, parent_id, path
|
||||
FROM paliad.projects
|
||||
WHERE paliad.can_see_project(id)
|
||||
AND status = 'active'
|
||||
ORDER BY path
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
#### 3. get_project_detail (per slug oder id)
|
||||
|
||||
```sql
|
||||
SELECT p.*,
|
||||
(SELECT json_agg(d ORDER BY d.due_date)
|
||||
FROM paliad.deadlines d WHERE d.project_id = p.id
|
||||
AND paliad.can_see_project(d.project_id)) AS deadlines,
|
||||
(SELECT json_agg(a ORDER BY a.start_at)
|
||||
FROM paliad.appointments a WHERE a.project_id = p.id
|
||||
AND paliad.can_see_project(a.project_id)) AS appointments,
|
||||
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
|
||||
FROM paliad.projects p
|
||||
WHERE paliad.can_see_project(p.id)
|
||||
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
#### 4. search_my_deadlines (status / Datum / Projekt)
|
||||
|
||||
```sql
|
||||
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND ($status::text IS NULL OR d.status = $status)
|
||||
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
|
||||
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
|
||||
ORDER BY d.due_date ASC
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
#### 5. list_my_appointments (Zeitfenster)
|
||||
|
||||
```sql
|
||||
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
|
||||
FROM paliad.appointments a
|
||||
LEFT JOIN paliad.projects p ON p.id = a.project_id
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at >= $from
|
||||
AND a.start_at <= $to
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
#### 6. lookup_court (Gerichtskatalog — firm-wide reference)
|
||||
|
||||
```sql
|
||||
SELECT c.slug, c.name, c.country, c.kind, c.address
|
||||
FROM paliad.courts c
|
||||
WHERE c.name ILIKE '%' || $q || '%'
|
||||
OR c.slug ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(c.name, $q) DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
#### 7. lookup_deadline_rule (Fristenrechner-Konzepte)
|
||||
|
||||
```sql
|
||||
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
|
||||
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
|
||||
FROM paliad.deadline_rules r
|
||||
WHERE r.concept_label ILIKE '%' || $q || '%'
|
||||
OR r.rule_code ILIKE '%' || $q || '%'
|
||||
OR r.legal_source ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(r.concept_label, $q) DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
#### 8. lookup_youpc_case (UPC-Rechtsprechung — cross-schema!)
|
||||
|
||||
```sql
|
||||
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
|
||||
j.proceedings_type, j.decision_date, j.headnote_summary,
|
||||
j.tags
|
||||
FROM data.judgments j
|
||||
WHERE j.upc_number ILIKE '%' || $q || '%'
|
||||
OR j.headnote_summary ILIKE '%' || $q || '%'
|
||||
OR j.tags::text ILIKE '%' || $q || '%'
|
||||
ORDER BY j.decision_date DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
Volltext eines Urteils:
|
||||
|
||||
```sql
|
||||
SELECT content
|
||||
FROM data.judgment_markdown_content
|
||||
WHERE judgment_node_id = <node_id>
|
||||
ORDER BY chunk_index
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
> **Hinweis Glossar:** Der Patent-Glossar ist statisch in `internal/handlers/glossary.go` (JSON beim Boot geladen). Für reine Begriffsfragen reicht dein Wissen + ein optionaler Cross-Check via `paliad.deadline_rules.legal_source`.
|
||||
|
||||
## Workflow pro Turn
|
||||
|
||||
1. **Lese die Anfrage** im Format `[PALIADIN:<turn_id>] <Frage>`. Behalte `<turn_id>` im Kopf — die Antwort-Datei heißt `<turn_id>.txt`.
|
||||
2. **Klassifiziere** mental: `data` / `concept` / `navigation` / `meta` / `other`. Das bestimmt deine Tool-Wahl und den `classifier_tag`.
|
||||
3. **Recherchiere** (max. 1–3 Tool-Calls für Performance — Backend timeout 60s).
|
||||
4. **Schreibe die Datei** mit `Write` Tool: `/tmp/paliadin/<turn_id>.txt` mit Antwort + Trailer.
|
||||
5. Optional: Im Chat-Pane einen einzeiligen Echo-Output (`done` o.ä.). Das Backend liest **nur** die Datei, der Pane-Output ist irrelevant.
|
||||
|
||||
## Bei der allerersten Anfrage einer Session
|
||||
|
||||
Du darfst dich kurz vorstellen ("Hi m, ich bin Paliadin — bereit."), aber **nur in der Antwort-Datei**, nicht stattdessen. Ab der zweiten Anfrage normaler Modus, ohne Vorstellung.
|
||||
Reference in New Issue
Block a user