Merge: t-paliad-155 — Paliadin skill-as-skill + per-user tmux session + project-MCP cwd + 120s timeout (skill at ~/.claude/skills/paliadin/SKILL.md replaces paliadin_prompt.go's keystroke-bootstrap; per-user session keying paliad-paliadin-<user_id_short>; shim spawns claude in /home/m/dev/paliad so project MCPs incl. supabase load; PALIADIN_TIMEOUT_S default 60→120s for cold-start safety; SKILL.md bans psql/curl fallbacks; install-paliadin-skill script for repo-as-source-of-truth; paliadin_prompt.go deprecated)
This commit is contained in:
@@ -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. |
|
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||||
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
|
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
|
||||||
| `PALIADIN_TMUX_SESSION` | optional (default `paliad-paliadin`) | tmux session name the Paliadin service uses for its long-lived `claude` pane. |
|
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
|
||||||
|
| `PALIADIN_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. |
|
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
|
||||||
|
|
||||||
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
|
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ func main() {
|
|||||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||||
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
|
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||||
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, tmuxSession, responseDir)
|
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||||
log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail)
|
log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail)
|
||||||
} else {
|
} else {
|
||||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||||
@@ -252,9 +252,10 @@ func main() {
|
|||||||
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
|
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
|
||||||
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
|
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
|
||||||
cfg := services.RemotePaliadinConfig{
|
cfg := services.RemotePaliadinConfig{
|
||||||
SSHHost: host,
|
SSHHost: host,
|
||||||
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
|
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
|
||||||
SSHPort: 22022,
|
SSHPort: 22022,
|
||||||
|
SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
|
||||||
}
|
}
|
||||||
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
|
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
|
||||||
n, err := strconv.Atoi(p)
|
n, err := strconv.Atoi(p)
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runPaliadinTurnAsync executes the turn and writes events into ch.
|
// 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) {
|
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||||
defer func() {
|
defer func() {
|
||||||
// Drain + close. The SSE handler reads until the channel closes.
|
// 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()
|
defer cancel()
|
||||||
|
|
||||||
result, err := paliadinSvc.RunTurn(ctx, req)
|
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) {
|
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requirePaliadinOwner(w, r) {
|
if !requirePaliadinOwner(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner
|
||||||
ctx, cancel := newDetachedContext(10 * time.Second)
|
ctx, cancel := newDetachedContext(10 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := paliadinSvc.ResetSession(ctx); err != nil {
|
if err := paliadinSvc.ResetSession(ctx, uid); err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||||
"error": "reset failed: " + err.Error(),
|
"error": "reset failed: " + err.Error(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
|
|||||||
// so callers don't have to nil-check on every entry point.
|
// so callers don't have to nil-check on every entry point.
|
||||||
type Paliadin interface {
|
type Paliadin interface {
|
||||||
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
|
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
|
||||||
ResetSession(ctx context.Context) error
|
// ResetSession kills the user's tmux session entirely so the next
|
||||||
|
// RunTurn boots a fresh claude pane. Per-user since each Paliad user
|
||||||
|
// has their own session (t-paliad-155).
|
||||||
|
ResetSession(ctx context.Context, userID uuid.UUID) error
|
||||||
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
|
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
|
||||||
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
|
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
|
||||||
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
|
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||||
@@ -73,21 +76,28 @@ type paliadinDB struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LocalPaliadinService runs the local tmux+claude PoC (t-paliad-146).
|
// LocalPaliadinService runs the local tmux+claude PoC (t-paliad-146).
|
||||||
// Hardcoded single-user, single-tmux-window scope. Used on m's laptop;
|
// Used on m's laptop; not deployed to prod (the Dokploy container has no
|
||||||
// not deployed to prod (the Dokploy container has no `claude` CLI —
|
// `claude` CLI — see RemotePaliadinService for that path).
|
||||||
// see RemotePaliadinService for that path).
|
//
|
||||||
|
// Per-user tmux session: every Paliad user gets their own session named
|
||||||
|
// `<sessionPrefix>-<userid8>` (first 8 hex chars of the user's UUID),
|
||||||
|
// created on demand. The persona + response protocol are loaded from
|
||||||
|
// the Paliadin skill (~/.claude/skills/paliadin/SKILL.md, installed via
|
||||||
|
// scripts/install-paliadin-skill); there is no in-process system prompt.
|
||||||
type LocalPaliadinService struct {
|
type LocalPaliadinService struct {
|
||||||
paliadinDB
|
paliadinDB
|
||||||
tmuxSession string
|
sessionPrefix string
|
||||||
responseDir string
|
responseDir string
|
||||||
|
|
||||||
// Cached pane target ("session:window-idx") once the voice window is
|
// Cached pane targets per user-session, keyed by tmux session name.
|
||||||
// either discovered or created. Reset to "" if the pane dies.
|
// A session entry maps to "session:window-idx"; cleared when the
|
||||||
mu sync.Mutex
|
// pane dies or ResetSession is called for that user.
|
||||||
paneTarget string
|
mu sync.Mutex
|
||||||
|
panes map[string]string
|
||||||
|
|
||||||
// Single in-flight turn at a time. PoC scope — one user (m), serialised
|
// Single in-flight turn at a time across all users. PoC scope —
|
||||||
// by a session-level mutex. Production v1 would queue / fan out.
|
// claude CLI panes share the host's terminal noise; serialising
|
||||||
|
// keeps log output unambiguous.
|
||||||
turnMu sync.Mutex
|
turnMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,22 +121,37 @@ func (s *paliadinDB) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error
|
|||||||
return strings.EqualFold(email, PaliadinOwnerEmail), nil
|
return strings.EqualFold(email, PaliadinOwnerEmail), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLocalPaliadinService wires the local-tmux PoC backend. Falls back
|
// NewLocalPaliadinService wires the local-tmux PoC backend. The
|
||||||
// to default tmux session + response dir when env vars are empty.
|
// sessionPrefix arg is the prefix every per-user tmux session inherits —
|
||||||
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *LocalPaliadinService {
|
// the actual session name is `<prefix>-<userid8>`. Falls back to
|
||||||
if tmuxSession == "" {
|
// defaults when env vars are empty.
|
||||||
tmuxSession = "paliad-paliadin"
|
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, sessionPrefix, responseDir string) *LocalPaliadinService {
|
||||||
|
if sessionPrefix == "" {
|
||||||
|
sessionPrefix = "paliad-paliadin"
|
||||||
}
|
}
|
||||||
if responseDir == "" {
|
if responseDir == "" {
|
||||||
responseDir = "/tmp/paliadin"
|
responseDir = "/tmp/paliadin"
|
||||||
}
|
}
|
||||||
return &LocalPaliadinService{
|
return &LocalPaliadinService{
|
||||||
paliadinDB: paliadinDB{db: db, users: users},
|
paliadinDB: paliadinDB{db: db, users: users},
|
||||||
tmuxSession: tmuxSession,
|
sessionPrefix: sessionPrefix,
|
||||||
responseDir: responseDir,
|
responseDir: responseDir,
|
||||||
|
panes: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionNameFor returns the tmux session name for a given user. Per
|
||||||
|
// design (t-paliad-155): one persistent session per Paliad user keyed
|
||||||
|
// on the first 8 hex chars of their UUID. Conversation history piles
|
||||||
|
// up across visits; `ResetSession` is the user-driven escape hatch.
|
||||||
|
func (s *LocalPaliadinService) sessionNameFor(userID uuid.UUID) string {
|
||||||
|
short := userID.String()
|
||||||
|
if len(short) >= 8 {
|
||||||
|
short = short[:8]
|
||||||
|
}
|
||||||
|
return s.sessionPrefix + "-" + short
|
||||||
|
}
|
||||||
|
|
||||||
// PaliadinTurn is the audit row.
|
// PaliadinTurn is the audit row.
|
||||||
type PaliadinTurn struct {
|
type PaliadinTurn struct {
|
||||||
TurnID uuid.UUID `db:"turn_id" json:"turn_id"`
|
TurnID uuid.UUID `db:"turn_id" json:"turn_id"`
|
||||||
@@ -199,8 +224,8 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
|
|||||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure tmux session + Claude pane.
|
// Ensure tmux session + Claude pane (per-user — keyed off UserID).
|
||||||
target, err := s.ensurePane(ctx)
|
target, err := s.ensurePane(ctx, req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err)
|
return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err)
|
||||||
@@ -212,8 +237,9 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
|
|||||||
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
|
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the framed prompt. The system prompt teaches Claude to react
|
// Send the framed prompt. The Paliadin skill at
|
||||||
// to the [PALIADIN:turn_id] envelope by writing the response file.
|
// ~/.claude/skills/paliadin/SKILL.md description-matches on this
|
||||||
|
// envelope and writes the response to the per-turn file.
|
||||||
envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage))
|
envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage))
|
||||||
if err := s.sendToPane(ctx, target, envelope); err != nil {
|
if err := s.sendToPane(ctx, target, envelope); err != nil {
|
||||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||||
@@ -260,20 +286,25 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetSession sends `/clear` to the Claude pane so the next turn starts
|
// ResetSession kills the user's tmux session entirely so the next
|
||||||
// from a clean conversation. Used by the "New conversation" button.
|
// RunTurn boots a fresh claude pane. With skill-based persona load
|
||||||
func (s *LocalPaliadinService) ResetSession(ctx context.Context) error {
|
// (~/.claude/skills/paliadin/SKILL.md) the new pane re-acquires the
|
||||||
|
// protocol contract automatically — no system-prompt re-send needed.
|
||||||
|
func (s *LocalPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
|
||||||
|
session := s.sessionNameFor(userID)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
target := s.paneTarget
|
delete(s.panes, session)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if target == "" {
|
|
||||||
// Nothing to reset; the next RunTurn will spin up a fresh pane.
|
// `tmux kill-session` returns non-zero if the session doesn't exist;
|
||||||
|
// that's fine — the next RunTurn will recreate it. Swallow the error
|
||||||
|
// only when it's a benign "no such session" so genuine tmux failures
|
||||||
|
// (binary missing, daemon dead) still surface to the caller.
|
||||||
|
if err := runTmux(ctx, "has-session", "-t", session); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := s.sendToPane(ctx, target, "/clear"); err != nil {
|
return runTmux(ctx, "kill-session", "-t", session)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRecentTurns reads the last N turns visible to the caller.
|
// ListRecentTurns reads the last N turns visible to the caller.
|
||||||
@@ -427,34 +458,40 @@ func (s *paliadinDB) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinSt
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// ensurePane returns the tmux target ("session:window-idx") of the live
|
// ensurePane returns the tmux target ("session:window-idx") of the live
|
||||||
// Claude pane, creating both session and window if missing.
|
// Claude pane for this user, creating both session and window if
|
||||||
func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
|
// missing. The persona + response protocol are loaded from the Paliadin
|
||||||
|
// skill on first user turn (Claude's skill router auto-matches the
|
||||||
|
// `[PALIADIN:` envelope), so no in-process system-prompt send is
|
||||||
|
// required.
|
||||||
|
func (s *LocalPaliadinService) ensurePane(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||||
|
session := s.sessionNameFor(userID)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
// Cheap path: if we have a cached target and it's still alive, reuse.
|
// Cheap path: cached target still alive? Reuse.
|
||||||
if s.paneTarget != "" && s.paneAlive(ctx, s.paneTarget) {
|
if cached, ok := s.panes[session]; ok && cached != "" && s.paneAlive(ctx, cached) {
|
||||||
return s.paneTarget, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure session.
|
// Ensure session.
|
||||||
if err := runTmux(ctx, "has-session", "-t", s.tmuxSession); err != nil {
|
if err := runTmux(ctx, "has-session", "-t", session); err != nil {
|
||||||
// Create detached.
|
// Create detached.
|
||||||
if err := runTmux(ctx, "new-session", "-d", "-s", s.tmuxSession); err != nil {
|
if err := runTmux(ctx, "new-session", "-d", "-s", session); err != nil {
|
||||||
return "", fmt.Errorf("new-session: %w", err)
|
return "", fmt.Errorf("new-session: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for an existing window tagged with @paliadin-scope=chat.
|
// Look for an existing window tagged with @paliadin-scope=chat.
|
||||||
if existing := s.findChatWindow(ctx); existing != "" {
|
if existing := s.findChatWindow(ctx, session); existing != "" {
|
||||||
s.paneTarget = existing
|
s.panes[session] = existing
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No window — create one running `claude` in a fresh pane. Must be
|
// No window — create one running `claude` in a fresh pane. Must be
|
||||||
// interactive: claude reads stdin, so the tmux pane behaves like a
|
// interactive: claude reads stdin, so the tmux pane behaves like a
|
||||||
// terminal. We use `new-window -P -F` to print the new index back.
|
// terminal. We use `new-window -P -F` to print the new index back.
|
||||||
out, err := runTmuxOut(ctx, "new-window", "-t", s.tmuxSession,
|
out, err := runTmuxOut(ctx, "new-window", "-t", session,
|
||||||
"-n", "claude-paliadin",
|
"-n", "claude-paliadin",
|
||||||
"-P", "-F", "#{window_index}",
|
"-P", "-F", "#{window_index}",
|
||||||
"claude")
|
"claude")
|
||||||
@@ -462,7 +499,7 @@ func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
|
|||||||
return "", fmt.Errorf("new-window claude: %w", err)
|
return "", fmt.Errorf("new-window claude: %w", err)
|
||||||
}
|
}
|
||||||
idx := strings.TrimSpace(out)
|
idx := strings.TrimSpace(out)
|
||||||
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
|
target := fmt.Sprintf("%s:%s", session, idx)
|
||||||
|
|
||||||
// Wait for Claude's prompt indicator. Claude Code's interactive
|
// Wait for Claude's prompt indicator. Claude Code's interactive
|
||||||
// prompt rendering varies but always settles into a state where the
|
// prompt rendering varies but always settles into a state where the
|
||||||
@@ -476,30 +513,18 @@ func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
|
|||||||
_ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat")
|
_ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat")
|
||||||
_ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin")
|
_ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin")
|
||||||
|
|
||||||
// Send the bootstrap system prompt so Claude knows who it is and how
|
s.panes[session] = target
|
||||||
// to reply (write to the per-turn file with [paliadin-meta] trailer).
|
|
||||||
if err := s.sendToPane(ctx, target, paliadinSystemPrompt(s.responseDir)); err != nil {
|
|
||||||
return "", fmt.Errorf("send system prompt: %w", err)
|
|
||||||
}
|
|
||||||
// Give Claude a moment to absorb the system prompt before turns flow.
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
|
|
||||||
s.paneTarget = target
|
|
||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalPaliadinService) findChatWindow(ctx context.Context) string {
|
func (s *LocalPaliadinService) findChatWindow(ctx context.Context, session string) string {
|
||||||
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
|
out, err := runTmuxOut(ctx, "list-windows", "-t", session,
|
||||||
"-F", "#{window_index}")
|
"-F", "#{window_index}")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
for _, idx := range strings.Fields(out) {
|
for _, idx := range strings.Fields(out) {
|
||||||
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
|
target := fmt.Sprintf("%s:%s", session, idx)
|
||||||
scope, err := runTmuxOut(ctx, "show-window-option",
|
scope, err := runTmuxOut(ctx, "show-window-option",
|
||||||
"-t", target, "-v", "@paliadin-scope")
|
"-t", target, "-v", "@paliadin-scope")
|
||||||
if err == nil && strings.TrimSpace(scope) == "chat" {
|
if err == nil && strings.TrimSpace(scope) == "chat" {
|
||||||
|
|||||||
@@ -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
|
SSHUser string // m
|
||||||
SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600)
|
SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600)
|
||||||
KnownHostsPath string // /tmp/paliadin-known_hosts
|
KnownHostsPath string // /tmp/paliadin-known_hosts
|
||||||
|
SessionPrefix string // tmux session prefix; per-user session is "<prefix>-<userid8>"
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemotePaliadinService implements Paliadin against a remote
|
// RemotePaliadinService implements Paliadin against a remote
|
||||||
@@ -60,27 +61,30 @@ type RemotePaliadinService struct {
|
|||||||
paliadinDB
|
paliadinDB
|
||||||
cfg RemotePaliadinConfig
|
cfg RemotePaliadinConfig
|
||||||
|
|
||||||
// Single in-flight turn. mRiver's claude pane is single-user; we
|
// Serialise turns across all users. mRiver's host has finite tmux
|
||||||
// serialise turns the same way LocalPaliadinService does.
|
// concurrency anyway, and Paliadin turns are short. Per-user
|
||||||
|
// fan-out can ship in v2 if it ever bottlenecks.
|
||||||
turnMu sync.Mutex
|
turnMu sync.Mutex
|
||||||
|
|
||||||
// Health-check cache. Avoids probing mRiver on every turn — once
|
// Health-check cache, keyed by per-user session name. Avoids
|
||||||
// the cache is warm, RunTurn skips the probe for 10 seconds.
|
// probing mRiver on every turn — once a session's cache is warm,
|
||||||
healthMu sync.Mutex
|
// RunTurn skips the probe for 10 seconds.
|
||||||
healthOK bool
|
healthMu sync.Mutex
|
||||||
healthCheckedAt time.Time
|
health map[string]healthCacheEntry
|
||||||
|
|
||||||
// Lazy bootstrap state. The system prompt only needs to be sent
|
|
||||||
// once per claude pane; on first RunTurn after a paliad restart we
|
|
||||||
// inject it, and remember we did so we don't re-send.
|
|
||||||
bootstrapMu sync.Mutex
|
|
||||||
bootstrapped bool
|
|
||||||
|
|
||||||
// Hook for tests — when non-nil, callShim delegates here instead
|
// Hook for tests — when non-nil, callShim delegates here instead
|
||||||
// of exec'ing ssh. Production code never sets this.
|
// of exec'ing ssh. Production code never sets this.
|
||||||
callShimHook func(ctx context.Context, args ...string) ([]byte, error)
|
callShimHook func(ctx context.Context, args ...string) ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healthCacheEntry is one row in the health cache, keyed off tmux
|
||||||
|
// session name. We cache success only — failures re-probe so a flap
|
||||||
|
// surfaces immediately when paliad reboots into a healthy mRiver.
|
||||||
|
type healthCacheEntry struct {
|
||||||
|
ok bool
|
||||||
|
checkedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// NewRemotePaliadinService wires the remote backend. Call only when
|
// NewRemotePaliadinService wires the remote backend. Call only when
|
||||||
// PALIADIN_REMOTE_HOST is set in the environment; the constructor does
|
// PALIADIN_REMOTE_HOST is set in the environment; the constructor does
|
||||||
// not probe mRiver — first probe happens on the first RunTurn call via
|
// not probe mRiver — first probe happens on the first RunTurn call via
|
||||||
@@ -92,12 +96,28 @@ func NewRemotePaliadinService(db *sqlx.DB, users *UserService, cfg RemotePaliadi
|
|||||||
if cfg.SSHUser == "" {
|
if cfg.SSHUser == "" {
|
||||||
cfg.SSHUser = "m"
|
cfg.SSHUser = "m"
|
||||||
}
|
}
|
||||||
|
if cfg.SessionPrefix == "" {
|
||||||
|
cfg.SessionPrefix = "paliad-paliadin"
|
||||||
|
}
|
||||||
return &RemotePaliadinService{
|
return &RemotePaliadinService{
|
||||||
paliadinDB: paliadinDB{db: db, users: users},
|
paliadinDB: paliadinDB{db: db, users: users},
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
health: make(map[string]healthCacheEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionNameFor returns the per-user tmux session name. Per-user
|
||||||
|
// keying (t-paliad-155): one persistent session per Paliad user keyed
|
||||||
|
// on the first 8 hex chars of their UUID. Conversation history piles
|
||||||
|
// up across visits; ResetSession is the user-driven escape hatch.
|
||||||
|
func (s *RemotePaliadinService) sessionNameFor(userID uuid.UUID) string {
|
||||||
|
short := userID.String()
|
||||||
|
if len(short) >= 8 {
|
||||||
|
short = short[:8]
|
||||||
|
}
|
||||||
|
return s.cfg.SessionPrefix + "-" + short
|
||||||
|
}
|
||||||
|
|
||||||
// RunTurn drives one Q&A round against the remote claude pane. Same
|
// RunTurn drives one Q&A round against the remote claude pane. Same
|
||||||
// audit-row contract as LocalPaliadinService: write the row first, run
|
// audit-row contract as LocalPaliadinService: write the row first, run
|
||||||
// the turn, complete the row on success, mark error on failure.
|
// the turn, complete the row on success, mark error on failure.
|
||||||
@@ -120,24 +140,25 @@ func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
|
|||||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session := s.sessionNameFor(req.UserID)
|
||||||
|
|
||||||
// Health-gate before paying the cost of a real turn. Caches OK for
|
// Health-gate before paying the cost of a real turn. Caches OK for
|
||||||
// 10 s so a fast back-to-back chat doesn't probe every time.
|
// 10 s per session so a fast back-to-back chat doesn't probe every
|
||||||
if err := s.healthGate(ctx); err != nil {
|
// time.
|
||||||
|
if err := s.healthGate(ctx, session); err != nil {
|
||||||
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
|
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy bootstrap — first turn after a paliad restart sends the
|
// Persona + response protocol live in the Paliadin skill at
|
||||||
// system prompt; subsequent turns skip.
|
// ~/.claude/skills/paliadin/SKILL.md on mRiver. Claude's skill
|
||||||
if err := s.ensureBootstrapped(ctx); err != nil {
|
// router auto-matches the [PALIADIN: envelope so no in-process
|
||||||
_ = s.markTurnError(ctx, turnID, "bootstrap_failed")
|
// bootstrap (system-prompt-via-tmux-keystroke) is needed any more.
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := sanitiseForTmux(req.UserMessage)
|
msg := sanitiseForTmux(req.UserMessage)
|
||||||
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
|
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
|
||||||
|
|
||||||
body, err := s.callShim(ctx, "run-turn", turnID.String(), msgB64)
|
body, err := s.callShim(ctx, "run-turn", session, turnID.String(), msgB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
|
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -165,55 +186,50 @@ func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetSession sends `/clear` to the remote claude pane.
|
// ResetSession kills the user's tmux session on mRiver entirely so the
|
||||||
func (s *RemotePaliadinService) ResetSession(ctx context.Context) error {
|
// next RunTurn boots a fresh claude pane. Skill-based persona load
|
||||||
if _, err := s.callShim(ctx, "reset"); err != nil {
|
// means the new pane re-acquires the Paliadin protocol contract on
|
||||||
return fmt.Errorf("paliadin: reset: %w", err)
|
// first turn — no system-prompt re-send needed.
|
||||||
|
func (s *RemotePaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
|
||||||
|
session := s.sessionNameFor(userID)
|
||||||
|
|
||||||
|
// Drop the cached health entry so the next turn re-probes against
|
||||||
|
// the fresh session.
|
||||||
|
s.healthMu.Lock()
|
||||||
|
delete(s.health, session)
|
||||||
|
s.healthMu.Unlock()
|
||||||
|
|
||||||
|
if _, err := s.callShim(ctx, "reset", session); err != nil {
|
||||||
|
return fmt.Errorf("paliadin: reset %s: %w", session, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// healthGate runs the shim's `health` verb at most once per 10 s.
|
// healthGate runs the shim's `health <session>` verb at most once per
|
||||||
// Returns ErrMRiverUnreachable wrapping the underlying error on miss.
|
// 10 s per session. Returns ErrMRiverUnreachable wrapping the
|
||||||
func (s *RemotePaliadinService) healthGate(ctx context.Context) error {
|
// underlying error on miss.
|
||||||
|
func (s *RemotePaliadinService) healthGate(ctx context.Context, session string) error {
|
||||||
s.healthMu.Lock()
|
s.healthMu.Lock()
|
||||||
defer s.healthMu.Unlock()
|
defer s.healthMu.Unlock()
|
||||||
|
|
||||||
if s.healthOK && time.Since(s.healthCheckedAt) < 10*time.Second {
|
if entry, ok := s.health[session]; ok && entry.ok && time.Since(entry.checkedAt) < 10*time.Second {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
out, err := s.callShim(probeCtx, "health")
|
out, err := s.callShim(probeCtx, "health", session)
|
||||||
s.healthCheckedAt = time.Now()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.healthOK = false
|
// Don't cache failures — re-probe on every miss so a recovery
|
||||||
|
// surfaces immediately.
|
||||||
|
delete(s.health, session)
|
||||||
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
|
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(string(out)) != "ok" {
|
if strings.TrimSpace(string(out)) != "ok" {
|
||||||
s.healthOK = false
|
delete(s.health, session)
|
||||||
return fmt.Errorf("%w: shim returned %q", ErrMRiverUnreachable, string(out))
|
return fmt.Errorf("%w: shim returned %q", ErrMRiverUnreachable, string(out))
|
||||||
}
|
}
|
||||||
s.healthOK = true
|
s.health[session] = healthCacheEntry{ok: true, checkedAt: time.Now()}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureBootstrapped sends the Paliadin system prompt to the remote
|
|
||||||
// claude pane on first call. Idempotent — subsequent calls return nil
|
|
||||||
// without doing any work.
|
|
||||||
func (s *RemotePaliadinService) ensureBootstrapped(ctx context.Context) error {
|
|
||||||
s.bootstrapMu.Lock()
|
|
||||||
defer s.bootstrapMu.Unlock()
|
|
||||||
if s.bootstrapped {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
prompt := paliadinSystemPrompt("/tmp/paliadin")
|
|
||||||
promptB64 := base64.StdEncoding.EncodeToString([]byte(prompt))
|
|
||||||
if _, err := s.callShim(ctx, "bootstrap", promptB64); err != nil {
|
|
||||||
return fmt.Errorf("paliadin: bootstrap: %w", err)
|
|
||||||
}
|
|
||||||
s.bootstrapped = true
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,8 +261,9 @@ func (s *RemotePaliadinService) callShim(ctx context.Context, args ...string) ([
|
|||||||
}
|
}
|
||||||
sshArgs = append(sshArgs, args...)
|
sshArgs = append(sshArgs, args...)
|
||||||
|
|
||||||
// Shim's run-turn timeout is 60 s; +10 s gives SSH some overhead.
|
// Shim's run-turn timeout is 120 s (cold start = claude boot + skill
|
||||||
c, cancel := context.WithTimeout(ctx, 70*time.Second)
|
// load + MCP discovery + first reasoning); +10 s gives SSH overhead.
|
||||||
|
c, cancel := context.WithTimeout(ctx, 130*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(c, "ssh", sshArgs...)
|
cmd := exec.CommandContext(c, "ssh", sshArgs...)
|
||||||
@@ -309,7 +326,7 @@ func (s *DisabledPaliadinService) RunTurn(ctx context.Context, req TurnRequest)
|
|||||||
return nil, ErrPaliadinDisabled
|
return nil, ErrPaliadinDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DisabledPaliadinService) ResetSession(ctx context.Context) error {
|
func (s *DisabledPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
|
||||||
return ErrPaliadinDisabled
|
return ErrPaliadinDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,15 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testSession is the per-user session name we pass into healthGate /
|
||||||
|
// callShim from tests. The shape mirrors what RunTurn would derive for
|
||||||
|
// a real user.
|
||||||
|
const testSession = "paliad-paliadin-deadbeef"
|
||||||
|
|
||||||
// Tests for the remote-Paliadin backend. Every test bypasses exec via
|
// Tests for the remote-Paliadin backend. Every test bypasses exec via
|
||||||
// the callShimHook field — no real ssh is ever invoked, no DB rows are
|
// the callShimHook field — no real ssh is ever invoked, no DB rows are
|
||||||
// written. Tests that would need DB I/O (audit row insert/complete on
|
// written. Tests that would need DB I/O (audit row insert/complete on
|
||||||
@@ -77,13 +84,13 @@ func TestHealthGate_CachesOnSuccess(t *testing.T) {
|
|||||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
if len(args) != 1 || args[0] != "health" {
|
if len(args) != 2 || args[0] != "health" || args[1] != testSession {
|
||||||
t.Errorf("unexpected callShim args: %v", args)
|
t.Errorf("unexpected callShim args: %v", args)
|
||||||
}
|
}
|
||||||
return []byte("ok\n"), nil
|
return []byte("ok\n"), nil
|
||||||
}
|
}
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
if err := s.healthGate(context.Background()); err != nil {
|
if err := s.healthGate(context.Background(), testSession); err != nil {
|
||||||
t.Fatalf("healthGate iteration %d: %v", i, err)
|
t.Fatalf("healthGate iteration %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +107,7 @@ func TestHealthGate_RetriesAfterFailure(t *testing.T) {
|
|||||||
return nil, errors.New("ssh: Connection refused")
|
return nil, errors.New("ssh: Connection refused")
|
||||||
}
|
}
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
err := s.healthGate(context.Background())
|
err := s.healthGate(context.Background(), testSession)
|
||||||
if !errors.Is(err, ErrMRiverUnreachable) {
|
if !errors.Is(err, ErrMRiverUnreachable) {
|
||||||
t.Errorf("iteration %d: err %v; want wrapping ErrMRiverUnreachable", i, err)
|
t.Errorf("iteration %d: err %v; want wrapping ErrMRiverUnreachable", i, err)
|
||||||
}
|
}
|
||||||
@@ -116,60 +123,32 @@ func TestHealthGate_RejectsUnexpectedReply(t *testing.T) {
|
|||||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
return []byte("not-ok"), nil
|
return []byte("not-ok"), nil
|
||||||
}
|
}
|
||||||
err := s.healthGate(context.Background())
|
err := s.healthGate(context.Background(), testSession)
|
||||||
if !errors.Is(err, ErrMRiverUnreachable) {
|
if !errors.Is(err, ErrMRiverUnreachable) {
|
||||||
t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for non-ok reply", err)
|
t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for non-ok reply", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureBootstrapped_RunsOnce(t *testing.T) {
|
func TestHealthGate_PerSessionCache(t *testing.T) {
|
||||||
|
// Two sessions must each get their own probe — caching is per-key,
|
||||||
|
// not global.
|
||||||
var calls int32
|
var calls int32
|
||||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
if len(args) != 2 || args[0] != "bootstrap" {
|
return []byte("ok"), nil
|
||||||
t.Errorf("unexpected callShim args: %v", args)
|
|
||||||
}
|
|
||||||
// args[1] is the base64'd system prompt — no need to decode in
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
for i := 0; i < 3; i++ {
|
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
|
||||||
if err := s.ensureBootstrapped(context.Background()); err != nil {
|
t.Fatalf("session A first probe: %v", err)
|
||||||
t.Fatalf("ensureBootstrapped iteration %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
if err := s.healthGate(context.Background(), "paliad-paliadin-bbbbbbbb"); err != nil {
|
||||||
t.Errorf("expected 1 callShim call (bootstrap is one-shot); got %d", got)
|
t.Fatalf("session B first probe: %v", err)
|
||||||
}
|
}
|
||||||
}
|
if err := s.healthGate(context.Background(), "paliad-paliadin-aaaaaaaa"); err != nil {
|
||||||
|
t.Fatalf("session A second 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 got := atomic.LoadInt32(&calls); got != 2 {
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||||
t.Errorf("expected 2 callShim calls (1 fail + 1 succeed; 3rd cached); got %d", got)
|
t.Errorf("expected 2 callShim calls (1 per session, A reuses cache on 3rd); got %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,14 +159,14 @@ func TestHealthGate_CacheExpires(t *testing.T) {
|
|||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
return []byte("ok"), nil
|
return []byte("ok"), nil
|
||||||
}
|
}
|
||||||
if err := s.healthGate(context.Background()); err != nil {
|
if err := s.healthGate(context.Background(), testSession); err != nil {
|
||||||
t.Fatalf("first probe: %v", err)
|
t.Fatalf("first probe: %v", err)
|
||||||
}
|
}
|
||||||
// Force the cached timestamp to expire.
|
// Force the cached timestamp to expire.
|
||||||
s.healthMu.Lock()
|
s.healthMu.Lock()
|
||||||
s.healthCheckedAt = time.Now().Add(-11 * time.Second)
|
s.health[testSession] = healthCacheEntry{ok: true, checkedAt: time.Now().Add(-11 * time.Second)}
|
||||||
s.healthMu.Unlock()
|
s.healthMu.Unlock()
|
||||||
if err := s.healthGate(context.Background()); err != nil {
|
if err := s.healthGate(context.Background(), testSession); err != nil {
|
||||||
t.Fatalf("second probe (expired cache): %v", err)
|
t.Fatalf("second probe (expired cache): %v", err)
|
||||||
}
|
}
|
||||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||||
@@ -195,6 +174,71 @@ func TestHealthGate_CacheExpires(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionNameFor_PerUser(t *testing.T) {
|
||||||
|
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||||
|
a := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||||
|
b := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
|
||||||
|
if got := s.sessionNameFor(a); got != "paliad-paliadin-aaaaaaaa" {
|
||||||
|
t.Errorf("session A = %q; want paliad-paliadin-aaaaaaaa", got)
|
||||||
|
}
|
||||||
|
if got := s.sessionNameFor(b); got != "paliad-paliadin-bbbbbbbb" {
|
||||||
|
t.Errorf("session B = %q; want paliad-paliadin-bbbbbbbb", got)
|
||||||
|
}
|
||||||
|
if s.sessionNameFor(a) == s.sessionNameFor(b) {
|
||||||
|
t.Error("distinct user IDs collapsed to the same session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionNameFor_HonoursPrefix(t *testing.T) {
|
||||||
|
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
|
||||||
|
SSHHost: "x",
|
||||||
|
SessionPrefix: "custom",
|
||||||
|
})
|
||||||
|
a := uuid.MustParse("12345678-1111-2222-3333-444444444444")
|
||||||
|
if got := s.sessionNameFor(a); got != "custom-12345678" {
|
||||||
|
t.Errorf("session = %q; want custom-12345678", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetSession_KillsPerUserSession(t *testing.T) {
|
||||||
|
var captured []string
|
||||||
|
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||||
|
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
|
captured = append([]string(nil), args...)
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||||
|
if err := s.ResetSession(context.Background(), uid); err != nil {
|
||||||
|
t.Fatalf("ResetSession: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"reset", "paliad-paliadin-aaaaaaaa"}
|
||||||
|
if len(captured) != 2 || captured[0] != want[0] || captured[1] != want[1] {
|
||||||
|
t.Errorf("callShim args = %v; want %v", captured, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetSession_DropsHealthCache(t *testing.T) {
|
||||||
|
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||||
|
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) { return []byte("ok"), nil }
|
||||||
|
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||||
|
session := s.sessionNameFor(uid)
|
||||||
|
|
||||||
|
// Warm the cache.
|
||||||
|
if err := s.healthGate(context.Background(), session); err != nil {
|
||||||
|
t.Fatalf("warm: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := s.health[session]; !ok {
|
||||||
|
t.Fatal("cache should be warm")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ResetSession(context.Background(), uid); err != nil {
|
||||||
|
t.Fatalf("ResetSession: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := s.health[session]; ok {
|
||||||
|
t.Error("ResetSession must drop the per-session health cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemotePaliadin_ImplementsPaliadin(t *testing.T) {
|
func TestRemotePaliadin_ImplementsPaliadin(t *testing.T) {
|
||||||
// Compile-time check is in paliadin_remote.go; this test makes the
|
// Compile-time check is in paliadin_remote.go; this test makes the
|
||||||
// failure mode obvious if someone accidentally drops a method.
|
// failure mode obvious if someone accidentally drops a method.
|
||||||
@@ -208,7 +252,7 @@ func TestDisabledPaliadinService(t *testing.T) {
|
|||||||
if _, err := s.RunTurn(context.Background(), TurnRequest{}); !errors.Is(err, ErrPaliadinDisabled) {
|
if _, err := s.RunTurn(context.Background(), TurnRequest{}); !errors.Is(err, ErrPaliadinDisabled) {
|
||||||
t.Errorf("RunTurn error = %v; want ErrPaliadinDisabled", err)
|
t.Errorf("RunTurn error = %v; want ErrPaliadinDisabled", err)
|
||||||
}
|
}
|
||||||
if err := s.ResetSession(context.Background()); !errors.Is(err, ErrPaliadinDisabled) {
|
if err := s.ResetSession(context.Background(), uuid.Nil); !errors.Is(err, ErrPaliadinDisabled) {
|
||||||
t.Errorf("ResetSession error = %v; want ErrPaliadinDisabled", err)
|
t.Errorf("ResetSession error = %v; want ErrPaliadinDisabled", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
scripts/install-paliadin-skill
Executable file
37
scripts/install-paliadin-skill
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install-paliadin-skill — copy the Paliadin skill into the local Claude
|
||||||
|
# Code config so the long-lived `claude` pane on this host picks it up.
|
||||||
|
#
|
||||||
|
# Run on every host that hosts a Paliadin tmux session — that means:
|
||||||
|
# - mRiver (m's laptop, the prod target reached via SSH from paliad.de)
|
||||||
|
# - any laptop running paliad's LocalPaliadinService directly
|
||||||
|
#
|
||||||
|
# The skill at ~/.claude/skills/paliadin/SKILL.md is what teaches Claude
|
||||||
|
# to react to `[PALIADIN:<uuid>]` envelopes by writing the response to
|
||||||
|
# /tmp/paliadin/<uuid>.txt. It survives /clear and fresh sessions because
|
||||||
|
# Claude's skill router auto-matches by description, not by an in-memory
|
||||||
|
# system prompt.
|
||||||
|
#
|
||||||
|
# Idempotent — re-running after a repo update is the supported way to
|
||||||
|
# refresh the skill on a host.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
src_dir="$(cd "$(dirname "$0")/skills/paliadin" && pwd)"
|
||||||
|
dst_dir="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}/paliadin"
|
||||||
|
|
||||||
|
if [[ ! -f "$src_dir/SKILL.md" ]]; then
|
||||||
|
echo "install-paliadin-skill: missing $src_dir/SKILL.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$dst_dir"
|
||||||
|
# Mirror the entire skill tree (SKILL.md + references/), and clear out
|
||||||
|
# any stale auxiliary files left from a previous shape.
|
||||||
|
rm -rf "$dst_dir/references"
|
||||||
|
cp "$src_dir/SKILL.md" "$dst_dir/SKILL.md"
|
||||||
|
if [[ -d "$src_dir/references" ]]; then
|
||||||
|
cp -R "$src_dir/references" "$dst_dir/references"
|
||||||
|
fi
|
||||||
|
echo "installed: $dst_dir/"
|
||||||
|
find "$dst_dir" -type f -printf ' %P\n'
|
||||||
@@ -5,27 +5,43 @@
|
|||||||
# client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this
|
# client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this
|
||||||
# script parses it and dispatches to a fixed verb set.
|
# script parses it and dispatches to a fixed verb set.
|
||||||
#
|
#
|
||||||
# Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md §5.4.
|
# Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md §5.4 +
|
||||||
|
# t-paliad-155 (per-user session keying + skill-based persona).
|
||||||
#
|
#
|
||||||
# Verbs:
|
# Verbs (every verb takes the tmux session name as the first positional
|
||||||
# health -> "ok" iff tmux + claude reachable
|
# argument; per-user sessions are created on demand):
|
||||||
# bootstrap <prompt-base64> -> ensure pane + send system prompt
|
|
||||||
# run-turn <uuid> <msg-base64> -> send framed prompt, poll, return
|
|
||||||
# reset -> /clear the conversation
|
|
||||||
#
|
#
|
||||||
# All multi-character payloads (prompts, messages) are base64-encoded by
|
# health <session> -> "ok" iff tmux + claude reachable
|
||||||
# the Go caller so we never have to quote them through ssh's argv.
|
# run-turn <session> <uuid> <msg-base64> -> send framed prompt, poll, return
|
||||||
|
# reset <session> -> kill the session entirely
|
||||||
|
#
|
||||||
|
# The persona + response protocol live in the Paliadin skill at
|
||||||
|
# ~/.claude/skills/paliadin/SKILL.md (see scripts/skills/paliadin/SKILL.md
|
||||||
|
# in the repo). Claude's skill router auto-matches the [PALIADIN:<uuid>]
|
||||||
|
# envelope and writes the response to /tmp/paliadin/<uuid>.txt — that is
|
||||||
|
# the contract this shim polls on. There is no longer a bootstrap step.
|
||||||
|
#
|
||||||
|
# All multi-character payloads (messages) are base64-encoded by the Go
|
||||||
|
# caller so we never have to quote them through ssh's argv.
|
||||||
#
|
#
|
||||||
# Errors go to stderr with a non-zero exit. The Go side maps the exit
|
# Errors go to stderr with a non-zero exit. The Go side maps the exit
|
||||||
# status into a friendly error code.
|
# status into a friendly error code.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
readonly TMUX_SESSION="${PALIADIN_TMUX_SESSION:-paliad-paliadin}"
|
|
||||||
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
|
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
|
||||||
readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-60}"
|
readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-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 PANE_READY_S=60 # max wait for claude pane to settle
|
||||||
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
|
# Session names are constructed by the Go side as `paliad-paliadin-<userid8>`;
|
||||||
|
# allow the same shape m might dial by hand. Stays defensive against shell
|
||||||
|
# metacharacters since this string is interpolated into tmux targets.
|
||||||
|
readonly SESSION_RE='^[A-Za-z0-9_.-]{1,64}$'
|
||||||
|
|
||||||
mkdir -p "$RESPONSE_DIR"
|
mkdir -p "$RESPONSE_DIR"
|
||||||
chmod 700 "$RESPONSE_DIR"
|
chmod 700 "$RESPONSE_DIR"
|
||||||
@@ -41,12 +57,30 @@ verb="${argv[0]:-}"
|
|||||||
|
|
||||||
log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; }
|
log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; }
|
||||||
|
|
||||||
# ensure_pane creates the tmux session + claude window if missing, waits
|
# require_session validates argv[1] as a tmux session name. Echoes the
|
||||||
# for the pane to become ready, and prints the target identifier
|
# validated name on success; logs + exits on failure.
|
||||||
|
require_session() {
|
||||||
|
local s="${argv[1]:-}"
|
||||||
|
if [[ -z "$s" ]]; then
|
||||||
|
log_err "$verb: missing session name"; exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! "$s" =~ $SESSION_RE ]]; then
|
||||||
|
log_err "$verb: invalid session name"; exit 2
|
||||||
|
fi
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ensure_pane creates the named tmux session + claude window if missing,
|
||||||
|
# waits for the pane to become ready, and prints the target identifier
|
||||||
# ("session:window-idx") on stdout.
|
# ("session:window-idx") on stdout.
|
||||||
|
#
|
||||||
|
# Per-user sessions are independently namespaced inside tmux; multiple
|
||||||
|
# paliad-paliadin-* sessions can coexist on mRiver without interfering.
|
||||||
ensure_pane() {
|
ensure_pane() {
|
||||||
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
|
local session="$1"
|
||||||
tmux new-session -d -s "$TMUX_SESSION"
|
|
||||||
|
if ! tmux has-session -t "$session" 2>/dev/null; then
|
||||||
|
tmux new-session -d -s "$session"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Look for an existing window tagged with @paliadin-scope=chat.
|
# Look for an existing window tagged with @paliadin-scope=chat.
|
||||||
@@ -54,22 +88,26 @@ ensure_pane() {
|
|||||||
local idx scope
|
local idx scope
|
||||||
while read -r idx; do
|
while read -r idx; do
|
||||||
[[ -z "$idx" ]] && continue
|
[[ -z "$idx" ]] && continue
|
||||||
scope=$(tmux show-window-option -t "$TMUX_SESSION:$idx" -v @paliadin-scope 2>/dev/null || true)
|
scope=$(tmux show-window-option -t "$session:$idx" -v @paliadin-scope 2>/dev/null || true)
|
||||||
if [[ "$scope" == "chat" ]]; then
|
if [[ "$scope" == "chat" ]]; then
|
||||||
target="$TMUX_SESSION:$idx"
|
target="$session:$idx"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done < <(tmux list-windows -t "$TMUX_SESSION" -F '#{window_index}' 2>/dev/null || true)
|
done < <(tmux list-windows -t "$session" -F '#{window_index}' 2>/dev/null || true)
|
||||||
|
|
||||||
if [[ -z "$target" ]]; then
|
if [[ -z "$target" ]]; then
|
||||||
if ! command -v claude >/dev/null 2>&1; then
|
if ! command -v claude >/dev/null 2>&1; then
|
||||||
log_err "claude CLI not found in PATH"
|
log_err "claude CLI not found in PATH"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
idx=$(tmux new-window -t "$TMUX_SESSION" -n claude-paliadin -P -F '#{window_index}' claude)
|
if [[ ! -d "$CLAUDE_CWD" ]]; then
|
||||||
target="$TMUX_SESSION:$idx"
|
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 deadline=$(( $(date +%s) + PANE_READY_S ))
|
||||||
local pane=""
|
local pane=""
|
||||||
while [[ $(date +%s) -lt $deadline ]]; do
|
while [[ $(date +%s) -lt $deadline ]]; do
|
||||||
@@ -103,55 +141,47 @@ case "$verb" in
|
|||||||
health)
|
health)
|
||||||
# Used by the Go side's healthGate to short-circuit when mRiver is
|
# Used by the Go side's healthGate to short-circuit when mRiver is
|
||||||
# offline or tmux/claude is broken. Output is parsed verbatim.
|
# offline or tmux/claude is broken. Output is parsed verbatim.
|
||||||
|
# Session is required (per-user) but health is *not* expected to
|
||||||
|
# spin up the claude pane — only validates tooling + that we could
|
||||||
|
# in principle create the session.
|
||||||
|
session=$(require_session)
|
||||||
if ! command -v tmux >/dev/null 2>&1; then
|
if ! command -v tmux >/dev/null 2>&1; then
|
||||||
log_err "tmux not in PATH"; exit 1
|
log_err "tmux not in PATH"; exit 1
|
||||||
fi
|
fi
|
||||||
if ! command -v claude >/dev/null 2>&1; then
|
if ! command -v claude >/dev/null 2>&1; then
|
||||||
log_err "claude not in PATH"; exit 1
|
log_err "claude not in PATH"; exit 1
|
||||||
fi
|
fi
|
||||||
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
|
if ! tmux has-session -t "$session" 2>/dev/null; then
|
||||||
tmux new-session -d -s "$TMUX_SESSION"
|
tmux new-session -d -s "$session"
|
||||||
fi
|
fi
|
||||||
echo ok
|
echo ok
|
||||||
;;
|
;;
|
||||||
|
|
||||||
bootstrap)
|
|
||||||
# Inject the system prompt into a fresh claude pane. Idempotent —
|
|
||||||
# the Go side may call this repeatedly; tmux send-keys is harmless
|
|
||||||
# against a settled pane.
|
|
||||||
if [[ -z "${argv[1]:-}" ]]; then
|
|
||||||
log_err "bootstrap: missing prompt"; exit 2
|
|
||||||
fi
|
|
||||||
if ! prompt=$(printf '%s' "${argv[1]}" | base64 -d 2>/dev/null); then
|
|
||||||
log_err "bootstrap: invalid base64 prompt"; exit 2
|
|
||||||
fi
|
|
||||||
target=$(ensure_pane)
|
|
||||||
send_to_pane "$target" "$prompt"
|
|
||||||
sleep 2 # let claude absorb before turns flow
|
|
||||||
echo ok
|
|
||||||
;;
|
|
||||||
|
|
||||||
run-turn)
|
run-turn)
|
||||||
# $1 = turn_id (UUID), $2 = base64-encoded user message.
|
# $1 = session, $2 = turn_id (UUID), $3 = base64-encoded user message.
|
||||||
turn_id="${argv[1]:-}"
|
session=$(require_session)
|
||||||
|
turn_id="${argv[2]:-}"
|
||||||
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
|
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
|
||||||
log_err "run-turn: bad turn_id"; exit 2
|
log_err "run-turn: bad turn_id"; exit 2
|
||||||
fi
|
fi
|
||||||
if [[ -z "${argv[2]:-}" ]]; then
|
if [[ -z "${argv[3]:-}" ]]; then
|
||||||
log_err "run-turn: missing message"; exit 2
|
log_err "run-turn: missing message"; exit 2
|
||||||
fi
|
fi
|
||||||
if ! msg=$(printf '%s' "${argv[2]}" | base64 -d 2>/dev/null); then
|
if ! msg=$(printf '%s' "${argv[3]}" | base64 -d 2>/dev/null); then
|
||||||
log_err "run-turn: invalid base64 message"; exit 2
|
log_err "run-turn: invalid base64 message"; exit 2
|
||||||
fi
|
fi
|
||||||
target=$(ensure_pane)
|
target=$(ensure_pane "$session")
|
||||||
out="$RESPONSE_DIR/$turn_id.txt"
|
out="$RESPONSE_DIR/$turn_id.txt"
|
||||||
rm -f "$out"
|
rm -f "$out"
|
||||||
|
|
||||||
# Envelope matches paliadin_prompt.go's `[PALIADIN:turn_id] <msg>` shape.
|
# Envelope. The Paliadin skill (~/.claude/skills/paliadin/SKILL.md)
|
||||||
|
# description-matches on this exact prefix, so Claude routes to the
|
||||||
|
# skill on every turn regardless of conversation state — surviving
|
||||||
|
# /clear, fresh sessions, and pane restarts.
|
||||||
send_to_pane "$target" "[PALIADIN:$turn_id] $msg"
|
send_to_pane "$target" "[PALIADIN:$turn_id] $msg"
|
||||||
|
|
||||||
# Poll for the response file. Same shape as Go pollForResponse
|
# Poll for the response file. Same shape as Go pollForResponse
|
||||||
# (paliadin.go:530). Settle delay so we don't read mid-flush.
|
# (paliadin.go). Settle delay so we don't read mid-flush.
|
||||||
deadline=$(( $(date +%s) + TIMEOUT_S ))
|
deadline=$(( $(date +%s) + TIMEOUT_S ))
|
||||||
while [[ $(date +%s) -lt $deadline ]]; do
|
while [[ $(date +%s) -lt $deadline ]]; do
|
||||||
if [[ -s "$out" ]]; then
|
if [[ -s "$out" ]]; then
|
||||||
@@ -167,9 +197,14 @@ case "$verb" in
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
reset)
|
reset)
|
||||||
# Send `/clear` so the next turn starts a fresh conversation.
|
# Kill the user's session entirely so the next run-turn boots a
|
||||||
target=$(ensure_pane)
|
# fresh claude pane. With skill-based persona load, /clear would
|
||||||
send_to_pane "$target" "/clear"
|
# also work — but kill-session is simpler and removes any chance
|
||||||
|
# of leftover conversation state confusing the next turn.
|
||||||
|
session=$(require_session)
|
||||||
|
if tmux has-session -t "$session" 2>/dev/null; then
|
||||||
|
tmux kill-session -t "$session"
|
||||||
|
fi
|
||||||
echo ok
|
echo ok
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|||||||
97
scripts/skills/paliadin/SKILL.md
Normal file
97
scripts/skills/paliadin/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: paliadin
|
||||||
|
description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the polling Go service can return it to the user. Trigger on the literal `[PALIADIN:` prefix, even when m's question is short ("Hey", "wer bin ich?") and looks like normal chat — the prefix is the contract, not the question content. Persona: m's Patentpraxis-Plattform-Assistent — terse, juristisch präzise German, no emojis, every concrete claim backed by a tool-call.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Paliadin
|
||||||
|
|
||||||
|
You are the in-app AI assistant inside **Paliad**, m's Patentpraxis-Plattform für HLC-Kollegen. You help with daily patent-practice work: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
|
||||||
|
|
||||||
|
## Quick start — one turn
|
||||||
|
|
||||||
|
Every Paliad request looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[PALIADIN:<turn_id>] <Frage>
|
||||||
|
```
|
||||||
|
|
||||||
|
Per turn:
|
||||||
|
|
||||||
|
1. **Extract `<turn_id>`** from the prefix.
|
||||||
|
2. **Research** with tools (max 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/<turn_id>.txt", …)` containing the Markdown answer + `[paliadin-meta]` trailer.
|
||||||
|
4. (Optional) one-line echo in the chat pane (`done`). The backend reads only the file.
|
||||||
|
|
||||||
|
> Skip every greeting / preamble in the chat pane. The file is the user-visible artefact; everything else is irrelevant.
|
||||||
|
|
||||||
|
## Persona
|
||||||
|
|
||||||
|
- Direkt, kompetent, juristisch präzise — wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung.
|
||||||
|
- Default Deutsch (m's Arbeitssprache); auf englische Frage englisch antworten.
|
||||||
|
- Keine Floskeln, keine Emojis, kein "Ich helfe dir gerne!".
|
||||||
|
|
||||||
|
## Response-file format
|
||||||
|
|
||||||
|
```
|
||||||
|
<Markdown-Antwort>
|
||||||
|
|
||||||
|
---
|
||||||
|
[paliadin-meta]
|
||||||
|
used_tools: <komma-separierte Tool-Namen, leer wenn keiner>
|
||||||
|
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
|
||||||
|
classifier_tag: <data | concept | navigation | meta | other>
|
||||||
|
[/paliadin-meta]
|
||||||
|
```
|
||||||
|
|
||||||
|
`classifier_tag` — pick one:
|
||||||
|
|
||||||
|
| Wert | Wann |
|
||||||
|
|---|---|
|
||||||
|
| `data` | m fragt nach seinen eigenen Daten ("welche Frist…") |
|
||||||
|
| `concept` | juristischer Begriff/Verfahren ("was ist Klageerwiderung?") |
|
||||||
|
| `navigation` | Paliad-Seite/Funktion suchen ("wie öffne ich…") |
|
||||||
|
| `meta` | Frage über Paliadin selbst, oder Smalltalk |
|
||||||
|
| `other` | Web-Wissen, sonstige Recherche |
|
||||||
|
|
||||||
|
`used_tools` und `rows_seen` müssen parallel sein (Tool-N → Rows-N). Beide leer, wenn kein Tool benutzt.
|
||||||
|
|
||||||
|
## Action-Chips (optional)
|
||||||
|
|
||||||
|
Direkt im Antworttext einbetten — Paliad-Frontend rendert sie als Buttons:
|
||||||
|
|
||||||
|
- `[#deadline-OPEN:<id>]` — öffnet Fristen-Detail
|
||||||
|
- `[#projekt-OPEN:<slug>]` — öffnet Projekt-Detail
|
||||||
|
- `[chip:nav:/projects/abc-123]` — beliebige Navigation
|
||||||
|
- `[chip:filter:status=pending&due=this_week]` — gefilterter Inbox-Link
|
||||||
|
|
||||||
|
Nur IDs/Slugs benutzen, die du tatsächlich aus einem Tool-Call hast. **Niemals erfinden.**
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
1. **Keine Erfindungen.** Liefert ein Tool nichts, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
|
||||||
|
2. **Jede konkrete Aussage über m's Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
|
||||||
|
3. **Read-only.** Schreibe nichts in die DB. Wenn m etwas ändern will, sag wo in Paliad.
|
||||||
|
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS `paliad.can_see_project(project_id)` enthalten.
|
||||||
|
5. **Nicht über andere User spekulieren** — frag nach Projekt-ID/Slug, selbst wenn m sie namentlich erwähnt.
|
||||||
|
6. **Niemals auf `psql`, `curl PostgREST`, `nix-shell` oder andere DB-Fallbacks ausweichen.** Die einzig zulässige DB-Quelle ist `mcp__supabase__execute_sql` (project-scoped MCP). Wenn dieser Tool-Aufruf nicht verfügbar ist, schreibe sofort: *"DB nicht erreichbar — bitte paliad neu deployen oder PALIADIN_REMOTE_CWD prüfen."* mit `classifier_tag: meta`. Niemals 60+ Sekunden im Fallback-Tanz verbringen — der Backend-Timeout schlägt sonst zu, bevor du eine Antwort schreibst.
|
||||||
|
|
||||||
|
## Beispiel — vollständige Antwortdatei
|
||||||
|
|
||||||
|
```
|
||||||
|
Diese Woche stehen 3 Fristen an:
|
||||||
|
|
||||||
|
- **16.05.** Klageerwiderung Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
|
||||||
|
- **17.05.** Replik BMW v. Daimler [#deadline-OPEN:e92a01-3]
|
||||||
|
- **20.05.** Wiedereinsetzung Bosch-Patent [#deadline-OPEN:f31b09-7]
|
||||||
|
|
||||||
|
---
|
||||||
|
[paliadin-meta]
|
||||||
|
used_tools: search_my_deadlines
|
||||||
|
rows_seen: 3
|
||||||
|
classifier_tag: data
|
||||||
|
[/paliadin-meta]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Allererste Anfrage einer Session
|
||||||
|
|
||||||
|
Eine kurze Vorstellung in der **Antwort-Datei** ist erlaubt ("Hi m, ich bin Paliadin — bereit."), nie statt der Datei. Ab Turn 2 normaler Modus.
|
||||||
134
scripts/skills/paliadin/references/sql-recipes.md
Normal file
134
scripts/skills/paliadin/references/sql-recipes.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# SQL recipes — Paliadin tool catalogue
|
||||||
|
|
||||||
|
Read this file **before any project / deadline / appointment / court / glossary / deadline-rule / UPC-judgment lookup**. Every query goes through the Supabase MCP via `mcp__supabase__execute_sql`. Two schemas in the same physical DB:
|
||||||
|
|
||||||
|
- `paliad.*` — Patentpraxis-Daten (projects, deadlines, appointments, parties, courts, deadline_rules, users)
|
||||||
|
- `data.*` — youpc.org UPC case law (judgments, headnotes, knowledge graph)
|
||||||
|
|
||||||
|
Every project-scoped query MUST include `paliad.can_see_project(project_id)` — even when m is global_admin (see SKILL.md rule 4).
|
||||||
|
|
||||||
|
## 1. whats_on_my_plate — Dashboard-Übersicht
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM paliad.deadlines d
|
||||||
|
WHERE paliad.can_see_project(d.project_id)
|
||||||
|
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
|
||||||
|
(SELECT count(*) FROM paliad.deadlines d
|
||||||
|
WHERE paliad.can_see_project(d.project_id)
|
||||||
|
AND d.status = 'pending' AND d.due_date = current_date) AS today,
|
||||||
|
(SELECT count(*) FROM paliad.deadlines d
|
||||||
|
WHERE paliad.can_see_project(d.project_id)
|
||||||
|
AND d.status = 'pending'
|
||||||
|
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
|
||||||
|
(SELECT count(*) FROM paliad.appointments a
|
||||||
|
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||||
|
AND a.start_at::date = current_date) AS appointments_today;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. list_my_projects
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, kind, label, status, parent_id, path
|
||||||
|
FROM paliad.projects
|
||||||
|
WHERE paliad.can_see_project(id)
|
||||||
|
AND status = 'active'
|
||||||
|
ORDER BY path
|
||||||
|
LIMIT 25;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. get_project_detail (per slug oder id)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT p.*,
|
||||||
|
(SELECT json_agg(d ORDER BY d.due_date)
|
||||||
|
FROM paliad.deadlines d WHERE d.project_id = p.id
|
||||||
|
AND paliad.can_see_project(d.project_id)) AS deadlines,
|
||||||
|
(SELECT json_agg(a ORDER BY a.start_at)
|
||||||
|
FROM paliad.appointments a WHERE a.project_id = p.id
|
||||||
|
AND paliad.can_see_project(a.project_id)) AS appointments,
|
||||||
|
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
|
||||||
|
FROM paliad.projects p
|
||||||
|
WHERE paliad.can_see_project(p.id)
|
||||||
|
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. search_my_deadlines (status / Datum / Projekt)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
|
||||||
|
FROM paliad.deadlines d
|
||||||
|
JOIN paliad.projects p ON p.id = d.project_id
|
||||||
|
WHERE paliad.can_see_project(d.project_id)
|
||||||
|
AND ($status::text IS NULL OR d.status = $status)
|
||||||
|
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
|
||||||
|
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
|
||||||
|
ORDER BY d.due_date ASC
|
||||||
|
LIMIT 25;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. list_my_appointments (Zeitfenster)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
|
||||||
|
FROM paliad.appointments a
|
||||||
|
LEFT JOIN paliad.projects p ON p.id = a.project_id
|
||||||
|
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||||
|
AND a.start_at >= $from
|
||||||
|
AND a.start_at <= $to
|
||||||
|
ORDER BY a.start_at ASC
|
||||||
|
LIMIT 25;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. lookup_court (firm-wide reference)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT c.slug, c.name, c.country, c.kind, c.address
|
||||||
|
FROM paliad.courts c
|
||||||
|
WHERE c.name ILIKE '%' || $q || '%'
|
||||||
|
OR c.slug ILIKE '%' || $q || '%'
|
||||||
|
ORDER BY similarity(c.name, $q) DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. lookup_deadline_rule (Fristenrechner-Konzepte)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
|
||||||
|
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
|
||||||
|
FROM paliad.deadline_rules r
|
||||||
|
WHERE r.concept_label ILIKE '%' || $q || '%'
|
||||||
|
OR r.rule_code ILIKE '%' || $q || '%'
|
||||||
|
OR r.legal_source ILIKE '%' || $q || '%'
|
||||||
|
ORDER BY similarity(r.concept_label, $q) DESC
|
||||||
|
LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. lookup_youpc_case (UPC-Rechtsprechung — cross-schema)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
|
||||||
|
j.proceedings_type, j.decision_date, j.headnote_summary,
|
||||||
|
j.tags
|
||||||
|
FROM data.judgments j
|
||||||
|
WHERE j.upc_number ILIKE '%' || $q || '%'
|
||||||
|
OR j.headnote_summary ILIKE '%' || $q || '%'
|
||||||
|
OR j.tags::text ILIKE '%' || $q || '%'
|
||||||
|
ORDER BY j.decision_date DESC
|
||||||
|
LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
Volltext eines Urteils (wenn m fragt "was steht in dem Urteil?"):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT content
|
||||||
|
FROM data.judgment_markdown_content
|
||||||
|
WHERE judgment_node_id = <node_id>
|
||||||
|
ORDER BY chunk_index
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Glossar — keine SQL-Tabelle
|
||||||
|
|
||||||
|
Der Patent-Glossar lebt statisch in `internal/handlers/glossary.go` (JSON beim Boot geladen). Für reine Begriffsfragen reicht dein Wissen + optional Cross-Check via `paliad.deadline_rules.legal_source`.
|
||||||
Reference in New Issue
Block a user