fix(docs): run i18n through a local rpc client

This commit is contained in:
Peter Steinberger
2026-03-16 02:08:09 +00:00
parent 6987a3c8b5
commit 39aba198f1
4 changed files with 483 additions and 26 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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")
}
}