From 39aba198f1530be2392e5e7e8a64606f31c95d83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:08:09 +0000 Subject: [PATCH] fix(docs): run i18n through a local rpc client --- scripts/docs-i18n/pi_command.go | 120 +++++++++++ scripts/docs-i18n/pi_rpc_client.go | 302 +++++++++++++++++++++++++++ scripts/docs-i18n/translator.go | 25 +-- scripts/docs-i18n/translator_test.go | 62 +++++- 4 files changed, 483 insertions(+), 26 deletions(-) create mode 100644 scripts/docs-i18n/pi_command.go create mode 100644 scripts/docs-i18n/pi_rpc_client.go diff --git a/scripts/docs-i18n/pi_command.go b/scripts/docs-i18n/pi_command.go new file mode 100644 index 00000000000..c11c9134453 --- /dev/null +++ b/scripts/docs-i18n/pi_command.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + envDocsPiExecutable = "OPENCLAW_DOCS_I18N_PI_EXECUTABLE" + envDocsPiArgs = "OPENCLAW_DOCS_I18N_PI_ARGS" + envDocsPiPackageVersion = "OPENCLAW_DOCS_I18N_PI_PACKAGE_VERSION" + defaultPiPackageVersion = "0.58.3" +) + +type docsPiCommand struct { + Executable string + Args []string +} + +var ( + materializedPiRuntimeMu sync.Mutex + materializedPiRuntimeCommand docsPiCommand + materializedPiRuntimeErr error +) + +func resolveDocsPiCommand(ctx context.Context) (docsPiCommand, error) { + if executable := strings.TrimSpace(os.Getenv(envDocsPiExecutable)); executable != "" { + return docsPiCommand{ + Executable: executable, + Args: strings.Fields(os.Getenv(envDocsPiArgs)), + }, nil + } + + piPath, err := exec.LookPath("pi") + if err == nil && !shouldMaterializePiRuntime(piPath) { + return docsPiCommand{Executable: piPath}, nil + } + + return ensureMaterializedPiRuntime(ctx) +} + +func shouldMaterializePiRuntime(piPath string) bool { + realPath, err := filepath.EvalSymlinks(piPath) + if err != nil { + realPath = piPath + } + return strings.Contains(filepath.ToSlash(realPath), "/Projects/pi-mono/") +} + +func ensureMaterializedPiRuntime(ctx context.Context) (docsPiCommand, error) { + materializedPiRuntimeMu.Lock() + defer materializedPiRuntimeMu.Unlock() + + if materializedPiRuntimeErr == nil && materializedPiRuntimeCommand.Executable != "" { + return materializedPiRuntimeCommand, nil + } + + runtimeDir, err := getMaterializedPiRuntimeDir() + if err != nil { + materializedPiRuntimeErr = err + return docsPiCommand{}, err + } + cliPath := filepath.Join(runtimeDir, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js") + if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) { + installCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + materializedPiRuntimeErr = err + return docsPiCommand{}, err + } + + packageVersion := getMaterializedPiPackageVersion() + install := exec.CommandContext( + installCtx, + "npm", + "install", + "--silent", + "--no-audit", + "--no-fund", + fmt.Sprintf("@mariozechner/pi-coding-agent@%s", packageVersion), + ) + install.Dir = runtimeDir + install.Env = os.Environ() + output, err := install.CombinedOutput() + if err != nil { + materializedPiRuntimeErr = fmt.Errorf("materialize pi runtime: %w (%s)", err, strings.TrimSpace(string(output))) + return docsPiCommand{}, materializedPiRuntimeErr + } + } + + materializedPiRuntimeCommand = docsPiCommand{ + Executable: "node", + Args: []string{cliPath}, + } + materializedPiRuntimeErr = nil + return materializedPiRuntimeCommand, nil +} + +func getMaterializedPiRuntimeDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + return filepath.Join(cacheDir, "openclaw", "docs-i18n", "pi-runtime", getMaterializedPiPackageVersion()), nil +} + +func getMaterializedPiPackageVersion() string { + if version := strings.TrimSpace(os.Getenv(envDocsPiPackageVersion)); version != "" { + return version + } + return defaultPiPackageVersion +} diff --git a/scripts/docs-i18n/pi_rpc_client.go b/scripts/docs-i18n/pi_rpc_client.go new file mode 100644 index 00000000000..d995c6a171f --- /dev/null +++ b/scripts/docs-i18n/pi_rpc_client.go @@ -0,0 +1,302 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +type docsPiClientOptions struct { + SystemPrompt string + Thinking string +} + +type docsPiClient struct { + process *exec.Cmd + stdin io.WriteCloser + stderr bytes.Buffer + events chan piEvent + promptLock sync.Mutex + closeOnce sync.Once + closed chan struct{} + requestID uint64 +} + +type piEvent struct { + Type string + Raw json.RawMessage +} + +type agentEndPayload struct { + Type string `json:"type,omitempty"` + Messages []agentMessage `json:"messages"` +} + +type rpcResponse struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Command string `json:"command,omitempty"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +type agentMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + StopReason string `json:"stopReason,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsPiClient, error) { + command, err := resolveDocsPiCommand(ctx) + if err != nil { + return nil, err + } + + args := append([]string{}, command.Args...) + args = append(args, + "--mode", "rpc", + "--provider", "anthropic", + "--model", modelVersion, + "--thinking", options.Thinking, + "--no-session", + ) + if strings.TrimSpace(options.SystemPrompt) != "" { + args = append(args, "--system-prompt", options.SystemPrompt) + } + + process := exec.Command(command.Executable, args...) + agentDir, err := getDocsPiAgentDir() + if err != nil { + return nil, err + } + process.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir)) + stdin, err := process.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := process.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := process.StderrPipe() + if err != nil { + return nil, err + } + + client := &docsPiClient{ + process: process, + stdin: stdin, + events: make(chan piEvent, 256), + closed: make(chan struct{}), + } + + if err := process.Start(); err != nil { + return nil, err + } + + go client.captureStderr(stderr) + go client.readStdout(stdout) + + return client, nil +} + +func (client *docsPiClient) Prompt(ctx context.Context, message string) (string, error) { + client.promptLock.Lock() + defer client.promptLock.Unlock() + + command := map[string]string{ + "type": "prompt", + "id": fmt.Sprintf("req-%d", atomic.AddUint64(&client.requestID, 1)), + "message": message, + } + payload, err := json.Marshal(command) + if err != nil { + return "", err + } + + if _, err := client.stdin.Write(append(payload, '\n')); err != nil { + return "", err + } + + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-client.closed: + return "", errors.New("pi process closed") + case event, ok := <-client.events: + if !ok { + return "", errors.New("pi event stream closed") + } + if event.Type == "response" { + response, err := decodeRpcResponse(event.Raw) + if err != nil { + return "", err + } + if !response.Success { + if strings.TrimSpace(response.Error) == "" { + return "", errors.New("pi prompt failed") + } + return "", errors.New(strings.TrimSpace(response.Error)) + } + continue + } + if event.Type == "agent_end" { + return extractTranslationResult(event.Raw) + } + } + } +} + +func (client *docsPiClient) Stderr() string { + return client.stderr.String() +} + +func (client *docsPiClient) Close() error { + client.closeOnce.Do(func() { + close(client.closed) + if client.stdin != nil { + _ = client.stdin.Close() + } + if client.process != nil && client.process.Process != nil { + _ = client.process.Process.Signal(syscall.SIGTERM) + } + + done := make(chan struct{}) + go func() { + if client.process != nil { + _ = client.process.Wait() + } + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + if client.process != nil && client.process.Process != nil { + _ = client.process.Process.Kill() + } + } + }) + return nil +} + +func (client *docsPiClient) captureStderr(stderr io.Reader) { + _, _ = io.Copy(&client.stderr, stderr) +} + +func (client *docsPiClient) readStdout(stdout io.Reader) { + defer close(client.events) + + reader := bufio.NewReader(stdout) + for { + line, err := reader.ReadBytes('\n') + line = bytes.TrimSpace(line) + if len(line) > 0 { + var envelope struct { + Type string `json:"type"` + } + if json.Unmarshal(line, &envelope) == nil && envelope.Type != "" { + select { + case client.events <- piEvent{Type: envelope.Type, Raw: append([]byte{}, line...)}: + case <-client.closed: + return + } + } + } + if err != nil { + return + } + } +} + +func extractTranslationResult(raw json.RawMessage) (string, error) { + var payload agentEndPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return "", err + } + for index := len(payload.Messages) - 1; index >= 0; index-- { + message := payload.Messages[index] + if message.Role != "assistant" { + continue + } + if message.ErrorMessage != "" || strings.EqualFold(message.StopReason, "error") { + msg := strings.TrimSpace(message.ErrorMessage) + if msg == "" { + msg = "unknown error" + } + return "", fmt.Errorf("pi error: %s", msg) + } + text, err := extractContentText(message.Content) + if err != nil { + return "", err + } + return text, nil + } + return "", errors.New("assistant message not found") +} + +func extractContentText(content json.RawMessage) (string, error) { + trimmed := strings.TrimSpace(string(content)) + if trimmed == "" { + return "", nil + } + if strings.HasPrefix(trimmed, "\"") { + var text string + if err := json.Unmarshal(content, &text); err != nil { + return "", err + } + return text, nil + } + + var blocks []contentBlock + if err := json.Unmarshal(content, &blocks); err != nil { + return "", err + } + + var parts []string + for _, block := range blocks { + if block.Type == "text" && block.Text != "" { + parts = append(parts, block.Text) + } + } + return strings.Join(parts, ""), nil +} + +func decodeRpcResponse(raw json.RawMessage) (rpcResponse, error) { + var response rpcResponse + if err := json.Unmarshal(raw, &response); err != nil { + return rpcResponse{}, err + } + return response, nil +} + +func getDocsPiAgentDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + dir := filepath.Join(cacheDir, "openclaw", "docs-i18n", "agent") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index 8f7023c615b..122a30ec5d5 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -6,8 +6,6 @@ import ( "fmt" "strings" "time" - - pi "github.com/joshp123/pi-golang" ) const ( @@ -19,21 +17,14 @@ const ( var errEmptyTranslation = errors.New("empty translation") type PiTranslator struct { - client *pi.OneShotClient + client *docsPiClient } func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) { - options := pi.DefaultOneShotOptions() - options.AppName = "openclaw-docs-i18n" - options.WorkDir = "/tmp" - options.Mode = pi.ModeDragons - options.Dragons = pi.DragonsOptions{ - Provider: "anthropic", - Model: modelVersion, - Thinking: normalizeThinking(thinking), - } - options.SystemPrompt = translationPrompt(srcLang, tgtLang, glossary) - client, err := pi.StartOneShot(options) + client, err := startDocsPiClient(context.Background(), docsPiClientOptions{ + SystemPrompt: translationPrompt(srcLang, tgtLang, glossary), + Thinking: normalizeThinking(thinking), + }) if err != nil { return nil, err } @@ -146,7 +137,7 @@ func (t *PiTranslator) Close() { } type promptRunner interface { - Run(context.Context, string) (pi.RunResult, error) + Prompt(context.Context, string) (string, error) Stderr() string } @@ -154,11 +145,11 @@ func runPrompt(ctx context.Context, client promptRunner, message string) (string promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout) defer cancel() - result, err := client.Run(promptCtx, message) + result, err := client.Prompt(promptCtx, message) if err != nil { return "", decoratePromptError(err, client.Stderr()) } - return result.Text, nil + return result, nil } func decoratePromptError(err error, stderr string) error { diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go index a632e44e96e..3872d6dff07 100644 --- a/scripts/docs-i18n/translator_test.go +++ b/scripts/docs-i18n/translator_test.go @@ -3,20 +3,20 @@ package main import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" "time" - - pi "github.com/joshp123/pi-golang" ) type fakePromptRunner struct { - run func(context.Context, string) (pi.RunResult, error) + prompt func(context.Context, string) (string, error) stderr string } -func (runner fakePromptRunner) Run(ctx context.Context, message string) (pi.RunResult, error) { - return runner.run(ctx, message) +func (runner fakePromptRunner) Prompt(ctx context.Context, message string) (string, error) { + return runner.prompt(ctx, message) } func (runner fakePromptRunner) Stderr() string { @@ -28,7 +28,7 @@ func TestRunPromptAddsTimeout(t *testing.T) { var deadline time.Time client := fakePromptRunner{ - run: func(ctx context.Context, message string) (pi.RunResult, error) { + prompt: func(ctx context.Context, message string) (string, error) { var ok bool deadline, ok = ctx.Deadline() if !ok { @@ -37,7 +37,7 @@ func TestRunPromptAddsTimeout(t *testing.T) { if message != "Translate me" { t.Fatalf("unexpected message %q", message) } - return pi.RunResult{Text: "translated"}, nil + return "translated", nil }, } @@ -60,8 +60,8 @@ func TestRunPromptIncludesStderr(t *testing.T) { rootErr := errors.New("context deadline exceeded") client := fakePromptRunner{ - run: func(context.Context, string) (pi.RunResult, error) { - return pi.RunResult{}, rootErr + prompt: func(context.Context, string) (string, error) { + return "", rootErr }, stderr: "boom", } @@ -90,3 +90,47 @@ func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) { t.Fatalf("expected unchanged message, got %v", got) } } + +func TestResolveDocsPiCommandUsesOverrideEnv(t *testing.T) { + t.Setenv(envDocsPiExecutable, "/tmp/custom-pi") + t.Setenv(envDocsPiArgs, "--mode rpc --foo bar") + + command, err := resolveDocsPiCommand(context.Background()) + if err != nil { + t.Fatalf("resolveDocsPiCommand returned error: %v", err) + } + + if command.Executable != "/tmp/custom-pi" { + t.Fatalf("unexpected executable %q", command.Executable) + } + if strings.Join(command.Args, " ") != "--mode rpc --foo bar" { + t.Fatalf("unexpected args %v", command.Args) + } +} + +func TestShouldMaterializePiRuntimeForPiMonoWrapper(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sourceDir := filepath.Join(root, "Projects", "pi-mono", "packages", "coding-agent", "dist") + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source dir: %v", err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + + target := filepath.Join(sourceDir, "cli.js") + if err := os.WriteFile(target, []byte("console.log('pi');\n"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(binDir, "pi") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + + if !shouldMaterializePiRuntime(link) { + t.Fatal("expected pi-mono wrapper to materialize runtime") + } +}