|
|
|
@@ -1,23 +1,23 @@
|
|
|
|
package services
|
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
|
|
// PaliadinService — Phase 0 PoC of the in-app AI buddy (t-paliad-146).
|
|
|
|
// Paliadin — the in-app AI buddy. Two implementations of the same
|
|
|
|
|
|
|
|
// interface, picked at boot time (see cmd/server/main.go):
|
|
|
|
//
|
|
|
|
//
|
|
|
|
// Design: docs/design-paliadin-2026-05-07.md §0.5 (PoC track).
|
|
|
|
// - LocalPaliadinService — talks to a `claude` CLI in a local tmux
|
|
|
|
|
|
|
|
// session. The PoC path (t-paliad-146); used on m's laptop.
|
|
|
|
|
|
|
|
// - RemotePaliadinService — shells out to ssh on mRiver where the
|
|
|
|
|
|
|
|
// long-lived tmux+claude pane lives. The prod path (t-paliad-151);
|
|
|
|
|
|
|
|
// used by the paliad.de Dokploy container, which has no `claude`
|
|
|
|
|
|
|
|
// CLI of its own.
|
|
|
|
//
|
|
|
|
//
|
|
|
|
// Architecture: a long-lived `claude` process inside a tmux session.
|
|
|
|
// Designs:
|
|
|
|
// Prompts go in via `tmux send-keys -l`; responses come back via a
|
|
|
|
// - docs/design-paliadin-2026-05-07.md (PoC architecture)
|
|
|
|
// per-turn file the system prompt instructs Claude to write
|
|
|
|
// - docs/design-paliadin-tailscale-ssh-2026-05-07.md (remote routing)
|
|
|
|
// (Write(/tmp/paliadin/{turn_id}.txt)). The service polls that file,
|
|
|
|
|
|
|
|
// strips the [paliadin-meta] trailer block, parses the metadata, writes
|
|
|
|
|
|
|
|
// an audit row, and emits the response back to the SSE handler.
|
|
|
|
|
|
|
|
//
|
|
|
|
//
|
|
|
|
// The architecture is lifted (with adaptation to Go) from
|
|
|
|
// Both implementations share the audit-table I/O (paliadinDB) and the
|
|
|
|
// ~/dev/mVoice/server.py:250-380, which has been driving the goldi voice
|
|
|
|
// trailer parser. The conversation state (turn ordering, response file
|
|
|
|
// surface in production since 2026-Q1.
|
|
|
|
// polling) is split: Local owns the tmux pane directly; Remote delegates
|
|
|
|
//
|
|
|
|
// to the paliadin-shim on mRiver and reads the file there.
|
|
|
|
// PoC ONLY runs on m's laptop (PALIADIN_ENABLED=false on prod default).
|
|
|
|
|
|
|
|
// Hardcoded single-user, single-tmux-window scope. Do not attempt to
|
|
|
|
|
|
|
|
// deploy this to the Dokploy container — there is no `claude` CLI there.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"bytes"
|
|
|
|
@@ -50,12 +50,36 @@ import (
|
|
|
|
// path to enabling Paliadin.
|
|
|
|
// path to enabling Paliadin.
|
|
|
|
const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
|
|
|
|
const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
|
|
|
|
|
|
|
|
|
|
|
|
// PaliadinService manages the tmux-claude PoC.
|
|
|
|
// Paliadin is the interface every Paliadin backend implements. Two
|
|
|
|
type PaliadinService struct {
|
|
|
|
// production implementations: LocalPaliadinService (local tmux+claude)
|
|
|
|
db *sqlx.DB
|
|
|
|
// and RemotePaliadinService (ssh+paliadin-shim on mRiver). A
|
|
|
|
|
|
|
|
// DisabledPaliadinService stub is constructed when neither is available
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// paliadinDB is the audit-table read/write surface shared by every
|
|
|
|
|
|
|
|
// Paliadin implementation. Embedded in LocalPaliadinService and
|
|
|
|
|
|
|
|
// RemotePaliadinService so they inherit IsOwner / ListRecentTurns /
|
|
|
|
|
|
|
|
// Stats and the per-turn row writers without duplication.
|
|
|
|
|
|
|
|
type paliadinDB struct {
|
|
|
|
|
|
|
|
db *sqlx.DB
|
|
|
|
|
|
|
|
users *UserService
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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).
|
|
|
|
|
|
|
|
type LocalPaliadinService struct {
|
|
|
|
|
|
|
|
paliadinDB
|
|
|
|
tmuxSession string
|
|
|
|
tmuxSession string
|
|
|
|
responseDir string
|
|
|
|
responseDir string
|
|
|
|
users *UserService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Cached pane target ("session:window-idx") once the voice window is
|
|
|
|
// Cached pane target ("session:window-idx") once the voice window is
|
|
|
|
// either discovered or created. Reset to "" if the pane dies.
|
|
|
|
// either discovered or created. Reset to "" if the pane dies.
|
|
|
|
@@ -74,7 +98,7 @@ type PaliadinService struct {
|
|
|
|
//
|
|
|
|
//
|
|
|
|
// Returns (false, nil) for any other user — including unknown UUIDs and
|
|
|
|
// Returns (false, nil) for any other user — including unknown UUIDs and
|
|
|
|
// users without an email row. Errors only on DB failure.
|
|
|
|
// users without an email row. Errors only on DB failure.
|
|
|
|
func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
|
|
|
|
func (s *paliadinDB) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
|
|
|
|
var email string
|
|
|
|
var email string
|
|
|
|
err := s.db.QueryRowxContext(ctx,
|
|
|
|
err := s.db.QueryRowxContext(ctx,
|
|
|
|
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
|
|
|
|
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
|
|
|
|
@@ -87,19 +111,19 @@ func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool,
|
|
|
|
return strings.EqualFold(email, PaliadinOwnerEmail), nil
|
|
|
|
return strings.EqualFold(email, PaliadinOwnerEmail), nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true.
|
|
|
|
// NewLocalPaliadinService wires the local-tmux PoC backend. Falls back
|
|
|
|
func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService {
|
|
|
|
// to default tmux session + response dir when env vars are empty.
|
|
|
|
|
|
|
|
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *LocalPaliadinService {
|
|
|
|
if tmuxSession == "" {
|
|
|
|
if tmuxSession == "" {
|
|
|
|
tmuxSession = "paliad-paliadin"
|
|
|
|
tmuxSession = "paliad-paliadin"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if responseDir == "" {
|
|
|
|
if responseDir == "" {
|
|
|
|
responseDir = "/tmp/paliadin"
|
|
|
|
responseDir = "/tmp/paliadin"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return &PaliadinService{
|
|
|
|
return &LocalPaliadinService{
|
|
|
|
db: db,
|
|
|
|
paliadinDB: paliadinDB{db: db, users: users},
|
|
|
|
tmuxSession: tmuxSession,
|
|
|
|
tmuxSession: tmuxSession,
|
|
|
|
responseDir: responseDir,
|
|
|
|
responseDir: responseDir,
|
|
|
|
users: users,
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -156,7 +180,7 @@ var ErrTmuxUnavailable = errors.New("paliadin: tmux unavailable")
|
|
|
|
//
|
|
|
|
//
|
|
|
|
// PoC: serialised. The package-level turnMu enforces "one at a time".
|
|
|
|
// PoC: serialised. The package-level turnMu enforces "one at a time".
|
|
|
|
// m is the only user, so this is fine.
|
|
|
|
// m is the only user, so this is fine.
|
|
|
|
func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
|
|
|
func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
|
|
|
s.turnMu.Lock()
|
|
|
|
s.turnMu.Lock()
|
|
|
|
defer s.turnMu.Unlock()
|
|
|
|
defer s.turnMu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
@@ -238,7 +262,7 @@ func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnRe
|
|
|
|
|
|
|
|
|
|
|
|
// ResetSession sends `/clear` to the Claude pane so the next turn starts
|
|
|
|
// ResetSession sends `/clear` to the Claude pane so the next turn starts
|
|
|
|
// from a clean conversation. Used by the "New conversation" button.
|
|
|
|
// from a clean conversation. Used by the "New conversation" button.
|
|
|
|
func (s *PaliadinService) ResetSession(ctx context.Context) error {
|
|
|
|
func (s *LocalPaliadinService) ResetSession(ctx context.Context) error {
|
|
|
|
s.mu.Lock()
|
|
|
|
s.mu.Lock()
|
|
|
|
target := s.paneTarget
|
|
|
|
target := s.paneTarget
|
|
|
|
s.mu.Unlock()
|
|
|
|
s.mu.Unlock()
|
|
|
|
@@ -254,7 +278,7 @@ func (s *PaliadinService) ResetSession(ctx context.Context) error {
|
|
|
|
|
|
|
|
|
|
|
|
// ListRecentTurns reads the last N turns visible to the caller.
|
|
|
|
// ListRecentTurns reads the last N turns visible to the caller.
|
|
|
|
// global_admin sees everything; everyone else sees their own.
|
|
|
|
// global_admin sees everything; everyone else sees their own.
|
|
|
|
func (s *PaliadinService) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) {
|
|
|
|
func (s *paliadinDB) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) {
|
|
|
|
if limit <= 0 || limit > 200 {
|
|
|
|
if limit <= 0 || limit > 200 {
|
|
|
|
limit = 50
|
|
|
|
limit = 50
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -302,7 +326,7 @@ type PaliadinPromptCount struct {
|
|
|
|
// Stats computes the dashboard aggregate. global_admin sees everything;
|
|
|
|
// Stats computes the dashboard aggregate. global_admin sees everything;
|
|
|
|
// everyone else sees their own slice (PoC has only m, but the policy
|
|
|
|
// everyone else sees their own slice (PoC has only m, but the policy
|
|
|
|
// matches RLS on the table).
|
|
|
|
// matches RLS on the table).
|
|
|
|
func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) {
|
|
|
|
func (s *paliadinDB) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) {
|
|
|
|
stats := &PaliadinStats{
|
|
|
|
stats := &PaliadinStats{
|
|
|
|
ByClassifier: map[string]int{},
|
|
|
|
ByClassifier: map[string]int{},
|
|
|
|
DailyCounts: []PaliadinDailyCount{},
|
|
|
|
DailyCounts: []PaliadinDailyCount{},
|
|
|
|
@@ -404,7 +428,7 @@ func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*Palia
|
|
|
|
|
|
|
|
|
|
|
|
// 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, creating both session and window if missing.
|
|
|
|
func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
|
|
|
|
func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
|
|
|
|
s.mu.Lock()
|
|
|
|
s.mu.Lock()
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
@@ -468,7 +492,7 @@ func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
|
|
|
|
return target, nil
|
|
|
|
return target, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) findChatWindow(ctx context.Context) string {
|
|
|
|
func (s *LocalPaliadinService) findChatWindow(ctx context.Context) string {
|
|
|
|
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
|
|
|
|
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
|
|
|
|
"-F", "#{window_index}")
|
|
|
|
"-F", "#{window_index}")
|
|
|
|
if err != nil {
|
|
|
|
if err != nil {
|
|
|
|
@@ -485,14 +509,14 @@ func (s *PaliadinService) findChatWindow(ctx context.Context) string {
|
|
|
|
return ""
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) paneAlive(ctx context.Context, target string) bool {
|
|
|
|
func (s *LocalPaliadinService) paneAlive(ctx context.Context, target string) bool {
|
|
|
|
if err := runTmux(ctx, "has-session", "-t", target); err != nil {
|
|
|
|
if err := runTmux(ctx, "has-session", "-t", target); err != nil {
|
|
|
|
return false
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error {
|
|
|
|
func (s *LocalPaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error {
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
select {
|
|
|
|
select {
|
|
|
|
@@ -509,7 +533,7 @@ func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, t
|
|
|
|
return fmt.Errorf("pane %s not ready within %s", target, timeout)
|
|
|
|
return fmt.Errorf("pane %s not ready within %s", target, timeout)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) error {
|
|
|
|
func (s *LocalPaliadinService) sendToPane(ctx context.Context, target, msg string) error {
|
|
|
|
// `-l` sends the message literally (no key parsing) — necessary so
|
|
|
|
// `-l` sends the message literally (no key parsing) — necessary so
|
|
|
|
// our prompt's special characters don't get interpreted.
|
|
|
|
// our prompt's special characters don't get interpreted.
|
|
|
|
if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil {
|
|
|
|
if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil {
|
|
|
|
@@ -527,7 +551,7 @@ func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) er
|
|
|
|
// over from earlier turns) as a non-event — the file existing without a
|
|
|
|
// over from earlier turns) as a non-event — the file existing without a
|
|
|
|
// fresh mtime is a corner case the caller already de-duplicates by
|
|
|
|
// fresh mtime is a corner case the caller already de-duplicates by
|
|
|
|
// having a unique turn_id per request.
|
|
|
|
// having a unique turn_id per request.
|
|
|
|
func (s *PaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) {
|
|
|
|
func (s *LocalPaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) {
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
select {
|
|
|
|
select {
|
|
|
|
@@ -687,7 +711,7 @@ func countChips(s string) int {
|
|
|
|
// audit-row writers.
|
|
|
|
// audit-row writers.
|
|
|
|
// =============================================================================
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) error {
|
|
|
|
func (s *paliadinDB) insertTurnRow(ctx context.Context, t *PaliadinTurn) error {
|
|
|
|
q := `
|
|
|
|
q := `
|
|
|
|
INSERT INTO paliad.paliadin_turns (
|
|
|
|
INSERT INTO paliad.paliadin_turns (
|
|
|
|
turn_id, user_id, session_id, started_at, user_message, page_origin
|
|
|
|
turn_id, user_id, session_id, started_at, user_message, page_origin
|
|
|
|
@@ -698,7 +722,7 @@ func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) er
|
|
|
|
return err
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID,
|
|
|
|
func (s *paliadinDB) completeTurn(ctx context.Context, turnID uuid.UUID,
|
|
|
|
finishedAt time.Time, durationMS int, response string, tokens int,
|
|
|
|
finishedAt time.Time, durationMS int, response string, tokens int,
|
|
|
|
meta trailerMeta, chipCount int) error {
|
|
|
|
meta trailerMeta, chipCount int) error {
|
|
|
|
rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen))
|
|
|
|
rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen))
|
|
|
|
@@ -724,7 +748,7 @@ func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID,
|
|
|
|
return err
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error {
|
|
|
|
func (s *paliadinDB) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error {
|
|
|
|
finished := time.Now().UTC()
|
|
|
|
finished := time.Now().UTC()
|
|
|
|
q := `
|
|
|
|
q := `
|
|
|
|
UPDATE paliad.paliadin_turns
|
|
|
|
UPDATE paliad.paliadin_turns
|
|
|
|
@@ -735,7 +759,7 @@ func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, c
|
|
|
|
return err
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *PaliadinService) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error {
|
|
|
|
func (s *paliadinDB) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error {
|
|
|
|
finished := time.Now().UTC()
|
|
|
|
finished := time.Now().UTC()
|
|
|
|
q := `
|
|
|
|
q := `
|
|
|
|
UPDATE paliad.paliadin_turns
|
|
|
|
UPDATE paliad.paliadin_turns
|
|
|
|
|