package replay import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "time" ) // DefaultRoot is what the CLI uses when --root isn't passed. func DefaultRoot() string { if r := os.Getenv("LH_DISTILL_ROOT"); r != "" { return r } if cwd, err := os.Getwd(); err == nil { return cwd } return "/home/profit/lakehouse" } // Replay runs the retrieve→prompt→model→validate→log pipeline. // Returns a ReplayResult that's already been appended to // data/_kb/replay_runs.jsonl unless DryRun + the file is read-only. // // Errors here are *infrastructure* failures (corpus unreadable, log // write failed). A failed model call OR a failed validation gate is // captured in ReplayResult.ValidationResult, not returned as error — // callers can branch on Passed / EscalationPath. func Replay(ctx context.Context, opts ReplayRequest, root string) (ReplayResult, error) { t0 := time.Now() recordedAt := time.Now().UTC().Format(time.RFC3339Nano) taskHash := sha256Hex(opts.Task) corpus, err := LoadRagCorpus(root) if err != nil { return ReplayResult{}, fmt.Errorf("load rag corpus: %w", err) } var bundle *ContextBundle if !opts.NoRetrieval { bundle = BuildContextBundle(corpus, opts.Task) } prompt := BuildPrompt(opts.Task, bundle) localModel := orDefault(opts.LocalModel, DefaultLocalModel) escalationModel := orDefault(opts.EscalationModel, DefaultEscalationModel) gatewayURL := orDefault(opts.GatewayURL, gatewayFromEnv()) caller := httpModelCaller(gatewayURL) if opts.DryRun { caller = dryRunCaller(opts.Task, bundle) } escalation := []string{localModel} modelUsed := localModel var modelResponse string var validation ValidationResult localCall := caller(ctx, localModel, prompt.System, prompt.User) if localCall.OK { modelResponse = localCall.Content validation = ValidateResponse(modelResponse, bundle) } else { validation = ValidationResult{ Passed: false, Reasons: []string{"local call failed: " + localCall.Error}, } } if !validation.Passed && opts.AllowEscalation && !opts.LocalOnly { escalation = append(escalation, escalationModel) escalCall := caller(ctx, escalationModel, prompt.System, prompt.User) if escalCall.OK { modelResponse = escalCall.Content modelUsed = escalationModel validation = ValidateResponse(modelResponse, bundle) if validation.Passed { validation.Reasons = append([]string{"recovered via escalation to " + escalationModel}, validation.Reasons...) } } else { validation.Reasons = append(validation.Reasons, "escalation also failed: "+escalCall.Error) } } recordedRunID := fmt.Sprintf("replay:%s:%s", taskHash[:16], sha256Hex(recordedAt)[:12], ) result := ReplayResult{ InputTask: opts.Task, TaskHash: taskHash, RetrievedArtifacts: RetrievedIDs{RagIDs: ragIDs(bundle)}, ContextBundle: bundle, ModelResponse: modelResponse, ModelUsed: modelUsed, EscalationPath: escalation, ValidationResult: validation, RecordedRunID: recordedRunID, RecordedAt: recordedAt, DurationMs: time.Since(t0).Milliseconds(), } if err := logReplayEvidence(root, result); err != nil { // Logging failure is real — surface it. The caller still gets the // in-memory result so they can inspect what happened. return result, fmt.Errorf("log replay evidence: %w", err) } return result, nil } // dryRunCaller wraps dryRunSynthesize as a ModelCaller. The escalation // branch in Replay calls the caller a second time; for parity with TS, // we return the same content suffixed with [ESCALATED] so a smoke can // detect escalation in dry-run mode. func dryRunCaller(task string, bundle *ContextBundle) ModelCaller { calls := 0 return func(_ context.Context, _ string, _ string, _ string) callModelResult { calls++ content := dryRunSynthesize(task, bundle) if calls >= 2 { content += "\n\n[ESCALATED]" } return callModelResult{Content: content, OK: true} } } // logReplayEvidence appends one row to data/_kb/replay_runs.jsonl. // model_response is truncated to 4000 chars in the persisted log to // keep the file lean (matches TS behavior). func logReplayEvidence(root string, result ReplayResult) error { path := filepath.Join(root, "data", "_kb", "replay_runs.jsonl") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } persist := struct { Schema string `json:"schema"` ReplayResult }{ Schema: "replay_run.v1", ReplayResult: result, } persist.ReplayResult.ModelResponse = trim(persist.ReplayResult.ModelResponse, 4000) buf, err := json.Marshal(persist) if err != nil { return err } buf = append(buf, '\n') f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() _, err = f.Write(buf) return err } func ragIDs(bundle *ContextBundle) []string { if bundle == nil { return []string{} } out := make([]string, 0, len(bundle.RetrievedPlaybooks)) for _, p := range bundle.RetrievedPlaybooks { out = append(out, p.RagID) } return out } func sha256Hex(s string) string { h := sha256.Sum256([]byte(s)) return hex.EncodeToString(h[:]) } func gatewayFromEnv() string { if u := os.Getenv("LH_GATEWAY_URL"); u != "" { return u } return DefaultGatewayURL } func orDefault(v, fallback string) string { if v == "" { return fallback } return v }